##// END OF EJS Templates
pull-requests: moved the delete logic into the show view....
marcink -
r1085:a6c56473 default
parent child Browse files
Show More
@@ -1,887 +1,889 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24
25 25 import peppercorn
26 26 import formencode
27 27 import logging
28 28
29 29 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
30 30 from pylons import request, tmpl_context as c, url
31 31 from pylons.controllers.util import redirect
32 32 from pylons.i18n.translation import _
33 33 from pyramid.threadlocal import get_current_registry
34 34 from sqlalchemy.sql import func
35 35 from sqlalchemy.sql.expression import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import auth, diffs, helpers as h
39 39 from rhodecode.lib.ext_json import json
40 40 from rhodecode.lib.base import (
41 41 BaseRepoController, render, vcs_operation_context)
42 42 from rhodecode.lib.auth import (
43 43 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
44 44 HasAcceptedRepoType, XHRRequired)
45 45 from rhodecode.lib.channelstream import channelstream_request
46 46 from rhodecode.lib.utils import jsonify
47 47 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
48 48 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
49 49 from rhodecode.lib.vcs.exceptions import (
50 50 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
51 51 from rhodecode.lib.diffs import LimitedDiffContainer
52 52 from rhodecode.model.changeset_status import ChangesetStatusModel
53 53 from rhodecode.model.comment import ChangesetCommentsModel
54 54 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
55 55 Repository
56 56 from rhodecode.model.forms import PullRequestForm
57 57 from rhodecode.model.meta import Session
58 58 from rhodecode.model.pull_request import PullRequestModel
59 59
60 60 log = logging.getLogger(__name__)
61 61
62 62
63 63 class PullrequestsController(BaseRepoController):
64 64 def __before__(self):
65 65 super(PullrequestsController, self).__before__()
66 66
67 67 def _load_compare_data(self, pull_request, enable_comments=True):
68 68 """
69 69 Load context data needed for generating compare diff
70 70
71 71 :param pull_request: object related to the request
72 72 :param enable_comments: flag to determine if comments are included
73 73 """
74 74 source_repo = pull_request.source_repo
75 75 source_ref_id = pull_request.source_ref_parts.commit_id
76 76
77 77 target_repo = pull_request.target_repo
78 78 target_ref_id = pull_request.target_ref_parts.commit_id
79 79
80 80 # despite opening commits for bookmarks/branches/tags, we always
81 81 # convert this to rev to prevent changes after bookmark or branch change
82 82 c.source_ref_type = 'rev'
83 83 c.source_ref = source_ref_id
84 84
85 85 c.target_ref_type = 'rev'
86 86 c.target_ref = target_ref_id
87 87
88 88 c.source_repo = source_repo
89 89 c.target_repo = target_repo
90 90
91 91 c.fulldiff = bool(request.GET.get('fulldiff'))
92 92
93 93 # diff_limit is the old behavior, will cut off the whole diff
94 94 # if the limit is applied otherwise will just hide the
95 95 # big files from the front-end
96 96 diff_limit = self.cut_off_limit_diff
97 97 file_limit = self.cut_off_limit_file
98 98
99 99 pre_load = ["author", "branch", "date", "message"]
100 100
101 101 c.commit_ranges = []
102 102 source_commit = EmptyCommit()
103 103 target_commit = EmptyCommit()
104 104 c.missing_requirements = False
105 105 try:
106 106 c.commit_ranges = [
107 107 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
108 108 for rev in pull_request.revisions]
109 109
110 110 c.statuses = source_repo.statuses(
111 111 [x.raw_id for x in c.commit_ranges])
112 112
113 113 target_commit = source_repo.get_commit(
114 114 commit_id=safe_str(target_ref_id))
115 115 source_commit = source_repo.get_commit(
116 116 commit_id=safe_str(source_ref_id))
117 117 except RepositoryRequirementError:
118 118 c.missing_requirements = True
119 119
120 120 c.missing_commits = False
121 121 if (c.missing_requirements or
122 122 isinstance(source_commit, EmptyCommit) or
123 123 source_commit == target_commit):
124 124 _parsed = []
125 125 c.missing_commits = True
126 126 else:
127 127 vcs_diff = PullRequestModel().get_diff(pull_request)
128 128 diff_processor = diffs.DiffProcessor(
129 129 vcs_diff, format='gitdiff', diff_limit=diff_limit,
130 130 file_limit=file_limit, show_full_diff=c.fulldiff)
131 131 _parsed = diff_processor.prepare()
132 132
133 133 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
134 134
135 135 c.files = []
136 136 c.changes = {}
137 137 c.lines_added = 0
138 138 c.lines_deleted = 0
139 139 c.included_files = []
140 140 c.deleted_files = []
141 141
142 142 for f in _parsed:
143 143 st = f['stats']
144 144 c.lines_added += st['added']
145 145 c.lines_deleted += st['deleted']
146 146
147 147 fid = h.FID('', f['filename'])
148 148 c.files.append([fid, f['operation'], f['filename'], f['stats']])
149 149 c.included_files.append(f['filename'])
150 150 html_diff = diff_processor.as_html(enable_comments=enable_comments,
151 151 parsed_lines=[f])
152 152 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
153 153
154 154 def _extract_ordering(self, request):
155 155 column_index = safe_int(request.GET.get('order[0][column]'))
156 156 order_dir = request.GET.get('order[0][dir]', 'desc')
157 157 order_by = request.GET.get(
158 158 'columns[%s][data][sort]' % column_index, 'name_raw')
159 159 return order_by, order_dir
160 160
161 161 @LoginRequired()
162 162 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
163 163 'repository.admin')
164 164 @HasAcceptedRepoType('git', 'hg')
165 165 def show_all(self, repo_name):
166 166 # filter types
167 167 c.active = 'open'
168 168 c.source = str2bool(request.GET.get('source'))
169 169 c.closed = str2bool(request.GET.get('closed'))
170 170 c.my = str2bool(request.GET.get('my'))
171 171 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
172 172 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
173 173 c.repo_name = repo_name
174 174
175 175 opened_by = None
176 176 if c.my:
177 177 c.active = 'my'
178 178 opened_by = [c.rhodecode_user.user_id]
179 179
180 180 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
181 181 if c.closed:
182 182 c.active = 'closed'
183 183 statuses = [PullRequest.STATUS_CLOSED]
184 184
185 185 if c.awaiting_review and not c.source:
186 186 c.active = 'awaiting'
187 187 if c.source and not c.awaiting_review:
188 188 c.active = 'source'
189 189 if c.awaiting_my_review:
190 190 c.active = 'awaiting_my'
191 191
192 192 data = self._get_pull_requests_list(
193 193 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
194 194 if not request.is_xhr:
195 195 c.data = json.dumps(data['data'])
196 196 c.records_total = data['recordsTotal']
197 197 return render('/pullrequests/pullrequests.html')
198 198 else:
199 199 return json.dumps(data)
200 200
201 201 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
202 202 # pagination
203 203 start = safe_int(request.GET.get('start'), 0)
204 204 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
205 205 order_by, order_dir = self._extract_ordering(request)
206 206
207 207 if c.awaiting_review:
208 208 pull_requests = PullRequestModel().get_awaiting_review(
209 209 repo_name, source=c.source, opened_by=opened_by,
210 210 statuses=statuses, offset=start, length=length,
211 211 order_by=order_by, order_dir=order_dir)
212 212 pull_requests_total_count = PullRequestModel(
213 213 ).count_awaiting_review(
214 214 repo_name, source=c.source, statuses=statuses,
215 215 opened_by=opened_by)
216 216 elif c.awaiting_my_review:
217 217 pull_requests = PullRequestModel().get_awaiting_my_review(
218 218 repo_name, source=c.source, opened_by=opened_by,
219 219 user_id=c.rhodecode_user.user_id, statuses=statuses,
220 220 offset=start, length=length, order_by=order_by,
221 221 order_dir=order_dir)
222 222 pull_requests_total_count = PullRequestModel(
223 223 ).count_awaiting_my_review(
224 224 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
225 225 statuses=statuses, opened_by=opened_by)
226 226 else:
227 227 pull_requests = PullRequestModel().get_all(
228 228 repo_name, source=c.source, opened_by=opened_by,
229 229 statuses=statuses, offset=start, length=length,
230 230 order_by=order_by, order_dir=order_dir)
231 231 pull_requests_total_count = PullRequestModel().count_all(
232 232 repo_name, source=c.source, statuses=statuses,
233 233 opened_by=opened_by)
234 234
235 235 from rhodecode.lib.utils import PartialRenderer
236 236 _render = PartialRenderer('data_table/_dt_elements.html')
237 237 data = []
238 238 for pr in pull_requests:
239 239 comments = ChangesetCommentsModel().get_all_comments(
240 240 c.rhodecode_db_repo.repo_id, pull_request=pr)
241 241
242 242 data.append({
243 243 'name': _render('pullrequest_name',
244 244 pr.pull_request_id, pr.target_repo.repo_name),
245 245 'name_raw': pr.pull_request_id,
246 246 'status': _render('pullrequest_status',
247 247 pr.calculated_review_status()),
248 248 'title': _render(
249 249 'pullrequest_title', pr.title, pr.description),
250 250 'description': h.escape(pr.description),
251 251 'updated_on': _render('pullrequest_updated_on',
252 252 h.datetime_to_time(pr.updated_on)),
253 253 'updated_on_raw': h.datetime_to_time(pr.updated_on),
254 254 'created_on': _render('pullrequest_updated_on',
255 255 h.datetime_to_time(pr.created_on)),
256 256 'created_on_raw': h.datetime_to_time(pr.created_on),
257 257 'author': _render('pullrequest_author',
258 258 pr.author.full_contact, ),
259 259 'author_raw': pr.author.full_name,
260 260 'comments': _render('pullrequest_comments', len(comments)),
261 261 'comments_raw': len(comments),
262 262 'closed': pr.is_closed(),
263 263 })
264 264 # json used to render the grid
265 265 data = ({
266 266 'data': data,
267 267 'recordsTotal': pull_requests_total_count,
268 268 'recordsFiltered': pull_requests_total_count,
269 269 })
270 270 return data
271 271
272 272 @LoginRequired()
273 273 @NotAnonymous()
274 274 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
275 275 'repository.admin')
276 276 @HasAcceptedRepoType('git', 'hg')
277 277 def index(self):
278 278 source_repo = c.rhodecode_db_repo
279 279
280 280 try:
281 281 source_repo.scm_instance().get_commit()
282 282 except EmptyRepositoryError:
283 283 h.flash(h.literal(_('There are no commits yet')),
284 284 category='warning')
285 285 redirect(url('summary_home', repo_name=source_repo.repo_name))
286 286
287 287 commit_id = request.GET.get('commit')
288 288 branch_ref = request.GET.get('branch')
289 289 bookmark_ref = request.GET.get('bookmark')
290 290
291 291 try:
292 292 source_repo_data = PullRequestModel().generate_repo_data(
293 293 source_repo, commit_id=commit_id,
294 294 branch=branch_ref, bookmark=bookmark_ref)
295 295 except CommitDoesNotExistError as e:
296 296 log.exception(e)
297 297 h.flash(_('Commit does not exist'), 'error')
298 298 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
299 299
300 300 default_target_repo = source_repo
301 301
302 302 if source_repo.parent:
303 303 parent_vcs_obj = source_repo.parent.scm_instance()
304 304 if parent_vcs_obj and not parent_vcs_obj.is_empty():
305 305 # change default if we have a parent repo
306 306 default_target_repo = source_repo.parent
307 307
308 308 target_repo_data = PullRequestModel().generate_repo_data(
309 309 default_target_repo)
310 310
311 311 selected_source_ref = source_repo_data['refs']['selected_ref']
312 312
313 313 title_source_ref = selected_source_ref.split(':', 2)[1]
314 314 c.default_title = PullRequestModel().generate_pullrequest_title(
315 315 source=source_repo.repo_name,
316 316 source_ref=title_source_ref,
317 317 target=default_target_repo.repo_name
318 318 )
319 319
320 320 c.default_repo_data = {
321 321 'source_repo_name': source_repo.repo_name,
322 322 'source_refs_json': json.dumps(source_repo_data),
323 323 'target_repo_name': default_target_repo.repo_name,
324 324 'target_refs_json': json.dumps(target_repo_data),
325 325 }
326 326 c.default_source_ref = selected_source_ref
327 327
328 328 return render('/pullrequests/pullrequest.html')
329 329
330 330 @LoginRequired()
331 331 @NotAnonymous()
332 332 @XHRRequired()
333 333 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
334 334 'repository.admin')
335 335 @jsonify
336 336 def get_repo_refs(self, repo_name, target_repo_name):
337 337 repo = Repository.get_by_repo_name(target_repo_name)
338 338 if not repo:
339 339 raise HTTPNotFound
340 340 return PullRequestModel().generate_repo_data(repo)
341 341
342 342 @LoginRequired()
343 343 @NotAnonymous()
344 344 @XHRRequired()
345 345 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
346 346 'repository.admin')
347 347 @jsonify
348 348 def get_repo_destinations(self, repo_name):
349 349 repo = Repository.get_by_repo_name(repo_name)
350 350 if not repo:
351 351 raise HTTPNotFound
352 352 filter_query = request.GET.get('query')
353 353
354 354 query = Repository.query() \
355 355 .order_by(func.length(Repository.repo_name)) \
356 356 .filter(or_(
357 357 Repository.repo_name == repo.repo_name,
358 358 Repository.fork_id == repo.repo_id))
359 359
360 360 if filter_query:
361 361 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
362 362 query = query.filter(
363 363 Repository.repo_name.ilike(ilike_expression))
364 364
365 365 add_parent = False
366 366 if repo.parent:
367 367 if filter_query in repo.parent.repo_name:
368 368 parent_vcs_obj = repo.parent.scm_instance()
369 369 if parent_vcs_obj and not parent_vcs_obj.is_empty():
370 370 add_parent = True
371 371
372 372 limit = 20 - 1 if add_parent else 20
373 373 all_repos = query.limit(limit).all()
374 374 if add_parent:
375 375 all_repos += [repo.parent]
376 376
377 377 repos = []
378 378 for obj in self.scm_model.get_repos(all_repos):
379 379 repos.append({
380 380 'id': obj['name'],
381 381 'text': obj['name'],
382 382 'type': 'repo',
383 383 'obj': obj['dbrepo']
384 384 })
385 385
386 386 data = {
387 387 'more': False,
388 388 'results': [{
389 389 'text': _('Repositories'),
390 390 'children': repos
391 391 }] if repos else []
392 392 }
393 393 return data
394 394
395 395 @LoginRequired()
396 396 @NotAnonymous()
397 397 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
398 398 'repository.admin')
399 399 @HasAcceptedRepoType('git', 'hg')
400 400 @auth.CSRFRequired()
401 401 def create(self, repo_name):
402 402 repo = Repository.get_by_repo_name(repo_name)
403 403 if not repo:
404 404 raise HTTPNotFound
405 405
406 406 controls = peppercorn.parse(request.POST.items())
407 407
408 408 try:
409 409 _form = PullRequestForm(repo.repo_id)().to_python(controls)
410 410 except formencode.Invalid as errors:
411 411 if errors.error_dict.get('revisions'):
412 412 msg = 'Revisions: %s' % errors.error_dict['revisions']
413 413 elif errors.error_dict.get('pullrequest_title'):
414 414 msg = _('Pull request requires a title with min. 3 chars')
415 415 else:
416 416 msg = _('Error creating pull request: {}').format(errors)
417 417 log.exception(msg)
418 418 h.flash(msg, 'error')
419 419
420 420 # would rather just go back to form ...
421 421 return redirect(url('pullrequest_home', repo_name=repo_name))
422 422
423 423 source_repo = _form['source_repo']
424 424 source_ref = _form['source_ref']
425 425 target_repo = _form['target_repo']
426 426 target_ref = _form['target_ref']
427 427 commit_ids = _form['revisions'][::-1]
428 428 reviewers = [
429 429 (r['user_id'], r['reasons']) for r in _form['review_members']]
430 430
431 431 # find the ancestor for this pr
432 432 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
433 433 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
434 434
435 435 source_scm = source_db_repo.scm_instance()
436 436 target_scm = target_db_repo.scm_instance()
437 437
438 438 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
439 439 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
440 440
441 441 ancestor = source_scm.get_common_ancestor(
442 442 source_commit.raw_id, target_commit.raw_id, target_scm)
443 443
444 444 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
445 445 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
446 446
447 447 pullrequest_title = _form['pullrequest_title']
448 448 title_source_ref = source_ref.split(':', 2)[1]
449 449 if not pullrequest_title:
450 450 pullrequest_title = PullRequestModel().generate_pullrequest_title(
451 451 source=source_repo,
452 452 source_ref=title_source_ref,
453 453 target=target_repo
454 454 )
455 455
456 456 description = _form['pullrequest_desc']
457 457 try:
458 458 pull_request = PullRequestModel().create(
459 459 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
460 460 target_ref, commit_ids, reviewers, pullrequest_title,
461 461 description
462 462 )
463 463 Session().commit()
464 464 h.flash(_('Successfully opened new pull request'),
465 465 category='success')
466 466 except Exception as e:
467 467 msg = _('Error occurred during sending pull request')
468 468 log.exception(msg)
469 469 h.flash(msg, category='error')
470 470 return redirect(url('pullrequest_home', repo_name=repo_name))
471 471
472 472 return redirect(url('pullrequest_show', repo_name=target_repo,
473 473 pull_request_id=pull_request.pull_request_id))
474 474
475 475 @LoginRequired()
476 476 @NotAnonymous()
477 477 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
478 478 'repository.admin')
479 479 @auth.CSRFRequired()
480 480 @jsonify
481 481 def update(self, repo_name, pull_request_id):
482 482 pull_request_id = safe_int(pull_request_id)
483 483 pull_request = PullRequest.get_or_404(pull_request_id)
484 484 # only owner or admin can update it
485 485 allowed_to_update = PullRequestModel().check_user_update(
486 486 pull_request, c.rhodecode_user)
487 487 if allowed_to_update:
488 488 controls = peppercorn.parse(request.POST.items())
489 489
490 490 if 'review_members' in controls:
491 491 self._update_reviewers(
492 492 pull_request_id, controls['review_members'])
493 493 elif str2bool(request.POST.get('update_commits', 'false')):
494 494 self._update_commits(pull_request)
495 495 elif str2bool(request.POST.get('close_pull_request', 'false')):
496 496 self._reject_close(pull_request)
497 497 elif str2bool(request.POST.get('edit_pull_request', 'false')):
498 498 self._edit_pull_request(pull_request)
499 499 else:
500 500 raise HTTPBadRequest()
501 501 return True
502 502 raise HTTPForbidden()
503 503
504 504 def _edit_pull_request(self, pull_request):
505 505 try:
506 506 PullRequestModel().edit(
507 507 pull_request, request.POST.get('title'),
508 508 request.POST.get('description'))
509 509 except ValueError:
510 510 msg = _(u'Cannot update closed pull requests.')
511 511 h.flash(msg, category='error')
512 512 return
513 513 else:
514 514 Session().commit()
515 515
516 516 msg = _(u'Pull request title & description updated.')
517 517 h.flash(msg, category='success')
518 518 return
519 519
520 520 def _update_commits(self, pull_request):
521 521 resp = PullRequestModel().update_commits(pull_request)
522 522
523 523 if resp.executed:
524 524 msg = _(
525 525 u'Pull request updated to "{source_commit_id}" with '
526 526 u'{count_added} added, {count_removed} removed commits.')
527 527 msg = msg.format(
528 528 source_commit_id=pull_request.source_ref_parts.commit_id,
529 529 count_added=len(resp.changes.added),
530 530 count_removed=len(resp.changes.removed))
531 531 h.flash(msg, category='success')
532 532
533 533 registry = get_current_registry()
534 534 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
535 535 channelstream_config = rhodecode_plugins.get('channelstream', {})
536 536 if channelstream_config.get('enabled'):
537 537 message = msg + (
538 538 ' - <a onclick="window.location.reload()">'
539 539 '<strong>{}</strong></a>'.format(_('Reload page')))
540 540 channel = '/repo${}$/pr/{}'.format(
541 541 pull_request.target_repo.repo_name,
542 542 pull_request.pull_request_id
543 543 )
544 544 payload = {
545 545 'type': 'message',
546 546 'user': 'system',
547 547 'exclude_users': [request.user.username],
548 548 'channel': channel,
549 549 'message': {
550 550 'message': message,
551 551 'level': 'success',
552 552 'topic': '/notifications'
553 553 }
554 554 }
555 555 channelstream_request(
556 556 channelstream_config, [payload], '/message',
557 557 raise_exc=False)
558 558 else:
559 559 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
560 560 warning_reasons = [
561 561 UpdateFailureReason.NO_CHANGE,
562 562 UpdateFailureReason.WRONG_REF_TPYE,
563 563 ]
564 564 category = 'warning' if resp.reason in warning_reasons else 'error'
565 565 h.flash(msg, category=category)
566 566
567 567 @auth.CSRFRequired()
568 568 @LoginRequired()
569 569 @NotAnonymous()
570 570 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
571 571 'repository.admin')
572 572 def merge(self, repo_name, pull_request_id):
573 573 """
574 574 POST /{repo_name}/pull-request/{pull_request_id}
575 575
576 576 Merge will perform a server-side merge of the specified
577 577 pull request, if the pull request is approved and mergeable.
578 578 After succesfull merging, the pull request is automatically
579 579 closed, with a relevant comment.
580 580 """
581 581 pull_request_id = safe_int(pull_request_id)
582 582 pull_request = PullRequest.get_or_404(pull_request_id)
583 583 user = c.rhodecode_user
584 584
585 585 if self._meets_merge_pre_conditions(pull_request, user):
586 586 log.debug("Pre-conditions checked, trying to merge.")
587 587 extras = vcs_operation_context(
588 588 request.environ, repo_name=pull_request.target_repo.repo_name,
589 589 username=user.username, action='push',
590 590 scm=pull_request.target_repo.repo_type)
591 591 self._merge_pull_request(pull_request, user, extras)
592 592
593 593 return redirect(url(
594 594 'pullrequest_show',
595 595 repo_name=pull_request.target_repo.repo_name,
596 596 pull_request_id=pull_request.pull_request_id))
597 597
598 598 def _meets_merge_pre_conditions(self, pull_request, user):
599 599 if not PullRequestModel().check_user_merge(pull_request, user):
600 600 raise HTTPForbidden()
601 601
602 602 merge_status, msg = PullRequestModel().merge_status(pull_request)
603 603 if not merge_status:
604 604 log.debug("Cannot merge, not mergeable.")
605 605 h.flash(msg, category='error')
606 606 return False
607 607
608 608 if (pull_request.calculated_review_status()
609 609 is not ChangesetStatus.STATUS_APPROVED):
610 610 log.debug("Cannot merge, approval is pending.")
611 611 msg = _('Pull request reviewer approval is pending.')
612 612 h.flash(msg, category='error')
613 613 return False
614 614 return True
615 615
616 616 def _merge_pull_request(self, pull_request, user, extras):
617 617 merge_resp = PullRequestModel().merge(
618 618 pull_request, user, extras=extras)
619 619
620 620 if merge_resp.executed:
621 621 log.debug("The merge was successful, closing the pull request.")
622 622 PullRequestModel().close_pull_request(
623 623 pull_request.pull_request_id, user)
624 624 Session().commit()
625 625 msg = _('Pull request was successfully merged and closed.')
626 626 h.flash(msg, category='success')
627 627 else:
628 628 log.debug(
629 629 "The merge was not successful. Merge response: %s",
630 630 merge_resp)
631 631 msg = PullRequestModel().merge_status_message(
632 632 merge_resp.failure_reason)
633 633 h.flash(msg, category='error')
634 634
635 635 def _update_reviewers(self, pull_request_id, review_members):
636 636 reviewers = [
637 637 (int(r['user_id']), r['reasons']) for r in review_members]
638 638 PullRequestModel().update_reviewers(pull_request_id, reviewers)
639 639 Session().commit()
640 640
641 641 def _reject_close(self, pull_request):
642 642 if pull_request.is_closed():
643 643 raise HTTPForbidden()
644 644
645 645 PullRequestModel().close_pull_request_with_comment(
646 646 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
647 647 Session().commit()
648 648
649 649 @LoginRequired()
650 650 @NotAnonymous()
651 651 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
652 652 'repository.admin')
653 653 @auth.CSRFRequired()
654 654 @jsonify
655 655 def delete(self, repo_name, pull_request_id):
656 656 pull_request_id = safe_int(pull_request_id)
657 657 pull_request = PullRequest.get_or_404(pull_request_id)
658 658 # only owner can delete it !
659 659 if pull_request.author.user_id == c.rhodecode_user.user_id:
660 660 PullRequestModel().delete(pull_request)
661 661 Session().commit()
662 662 h.flash(_('Successfully deleted pull request'),
663 663 category='success')
664 664 return redirect(url('my_account_pullrequests'))
665 665 raise HTTPForbidden()
666 666
667 667 @LoginRequired()
668 668 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
669 669 'repository.admin')
670 670 def show(self, repo_name, pull_request_id):
671 671 pull_request_id = safe_int(pull_request_id)
672 672 c.pull_request = PullRequest.get_or_404(pull_request_id)
673 673
674 674 c.template_context['pull_request_data']['pull_request_id'] = \
675 675 pull_request_id
676 676
677 677 # pull_requests repo_name we opened it against
678 678 # ie. target_repo must match
679 679 if repo_name != c.pull_request.target_repo.repo_name:
680 680 raise HTTPNotFound
681 681
682 682 c.allowed_to_change_status = PullRequestModel(). \
683 683 check_user_change_status(c.pull_request, c.rhodecode_user)
684 684 c.allowed_to_update = PullRequestModel().check_user_update(
685 685 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
686 686 c.allowed_to_merge = PullRequestModel().check_user_merge(
687 687 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
688 688 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
689 689 c.pull_request)
690 c.allowed_to_delete = PullRequestModel().check_user_delete(
691 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
690 692
691 693 cc_model = ChangesetCommentsModel()
692 694
693 695 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
694 696
695 697 c.pull_request_review_status = c.pull_request.calculated_review_status()
696 698 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
697 699 c.pull_request)
698 700 c.approval_msg = None
699 701 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
700 702 c.approval_msg = _('Reviewer approval is pending.')
701 703 c.pr_merge_status = False
702 704 # load compare data into template context
703 705 enable_comments = not c.pull_request.is_closed()
704 706 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
705 707
706 708 # this is a hack to properly display links, when creating PR, the
707 709 # compare view and others uses different notation, and
708 710 # compare_commits.html renders links based on the target_repo.
709 711 # We need to swap that here to generate it properly on the html side
710 712 c.target_repo = c.source_repo
711 713
712 714 # inline comments
713 715 c.inline_cnt = 0
714 716 c.inline_comments = cc_model.get_inline_comments(
715 717 c.rhodecode_db_repo.repo_id,
716 718 pull_request=pull_request_id).items()
717 719 # count inline comments
718 720 for __, lines in c.inline_comments:
719 721 for comments in lines.values():
720 722 c.inline_cnt += len(comments)
721 723
722 724 # outdated comments
723 725 c.outdated_cnt = 0
724 726 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
725 727 c.outdated_comments = cc_model.get_outdated_comments(
726 728 c.rhodecode_db_repo.repo_id,
727 729 pull_request=c.pull_request)
728 730 # Count outdated comments and check for deleted files
729 731 for file_name, lines in c.outdated_comments.iteritems():
730 732 for comments in lines.values():
731 733 c.outdated_cnt += len(comments)
732 734 if file_name not in c.included_files:
733 735 c.deleted_files.append(file_name)
734 736 else:
735 737 c.outdated_comments = {}
736 738
737 739 # comments
738 740 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
739 741 pull_request=pull_request_id)
740 742
741 743 if c.allowed_to_update:
742 744 force_close = ('forced_closed', _('Close Pull Request'))
743 745 statuses = ChangesetStatus.STATUSES + [force_close]
744 746 else:
745 747 statuses = ChangesetStatus.STATUSES
746 748 c.commit_statuses = statuses
747 749
748 750 c.ancestor = None # TODO: add ancestor here
749 751
750 752 return render('/pullrequests/pullrequest_show.html')
751 753
752 754 @LoginRequired()
753 755 @NotAnonymous()
754 756 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
755 757 'repository.admin')
756 758 @auth.CSRFRequired()
757 759 @jsonify
758 760 def comment(self, repo_name, pull_request_id):
759 761 pull_request_id = safe_int(pull_request_id)
760 762 pull_request = PullRequest.get_or_404(pull_request_id)
761 763 if pull_request.is_closed():
762 764 raise HTTPForbidden()
763 765
764 766 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
765 767 # as a changeset status, still we want to send it in one value.
766 768 status = request.POST.get('changeset_status', None)
767 769 text = request.POST.get('text')
768 770 if status and '_closed' in status:
769 771 close_pr = True
770 772 status = status.replace('_closed', '')
771 773 else:
772 774 close_pr = False
773 775
774 776 forced = (status == 'forced')
775 777 if forced:
776 778 status = 'rejected'
777 779
778 780 allowed_to_change_status = PullRequestModel().check_user_change_status(
779 781 pull_request, c.rhodecode_user)
780 782
781 783 if status and allowed_to_change_status:
782 784 message = (_('Status change %(transition_icon)s %(status)s')
783 785 % {'transition_icon': '>',
784 786 'status': ChangesetStatus.get_status_lbl(status)})
785 787 if close_pr:
786 788 message = _('Closing with') + ' ' + message
787 789 text = text or message
788 790 comm = ChangesetCommentsModel().create(
789 791 text=text,
790 792 repo=c.rhodecode_db_repo.repo_id,
791 793 user=c.rhodecode_user.user_id,
792 794 pull_request=pull_request_id,
793 795 f_path=request.POST.get('f_path'),
794 796 line_no=request.POST.get('line'),
795 797 status_change=(ChangesetStatus.get_status_lbl(status)
796 798 if status and allowed_to_change_status else None),
797 799 status_change_type=(status
798 800 if status and allowed_to_change_status else None),
799 801 closing_pr=close_pr
800 802 )
801 803
802 804
803 805
804 806 if allowed_to_change_status:
805 807 old_calculated_status = pull_request.calculated_review_status()
806 808 # get status if set !
807 809 if status:
808 810 ChangesetStatusModel().set_status(
809 811 c.rhodecode_db_repo.repo_id,
810 812 status,
811 813 c.rhodecode_user.user_id,
812 814 comm,
813 815 pull_request=pull_request_id
814 816 )
815 817
816 818 Session().flush()
817 819 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
818 820 # we now calculate the status of pull request, and based on that
819 821 # calculation we set the commits status
820 822 calculated_status = pull_request.calculated_review_status()
821 823 if old_calculated_status != calculated_status:
822 824 PullRequestModel()._trigger_pull_request_hook(
823 825 pull_request, c.rhodecode_user, 'review_status_change')
824 826
825 827 calculated_status_lbl = ChangesetStatus.get_status_lbl(
826 828 calculated_status)
827 829
828 830 if close_pr:
829 831 status_completed = (
830 832 calculated_status in [ChangesetStatus.STATUS_APPROVED,
831 833 ChangesetStatus.STATUS_REJECTED])
832 834 if forced or status_completed:
833 835 PullRequestModel().close_pull_request(
834 836 pull_request_id, c.rhodecode_user)
835 837 else:
836 838 h.flash(_('Closing pull request on other statuses than '
837 839 'rejected or approved is forbidden. '
838 840 'Calculated status from all reviewers '
839 841 'is currently: %s') % calculated_status_lbl,
840 842 category='warning')
841 843
842 844 Session().commit()
843 845
844 846 if not request.is_xhr:
845 847 return redirect(h.url('pullrequest_show', repo_name=repo_name,
846 848 pull_request_id=pull_request_id))
847 849
848 850 data = {
849 851 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
850 852 }
851 853 if comm:
852 854 c.co = comm
853 855 data.update(comm.get_dict())
854 856 data.update({'rendered_text':
855 857 render('changeset/changeset_comment_block.html')})
856 858
857 859 return data
858 860
859 861 @LoginRequired()
860 862 @NotAnonymous()
861 863 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
862 864 'repository.admin')
863 865 @auth.CSRFRequired()
864 866 @jsonify
865 867 def delete_comment(self, repo_name, comment_id):
866 868 return self._delete_comment(comment_id)
867 869
868 870 def _delete_comment(self, comment_id):
869 871 comment_id = safe_int(comment_id)
870 872 co = ChangesetComment.get_or_404(comment_id)
871 873 if co.pull_request.is_closed():
872 874 # don't allow deleting comments on closed pull request
873 875 raise HTTPForbidden()
874 876
875 877 is_owner = co.author.user_id == c.rhodecode_user.user_id
876 878 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
877 879 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
878 880 old_calculated_status = co.pull_request.calculated_review_status()
879 881 ChangesetCommentsModel().delete(comment=co)
880 882 Session().commit()
881 883 calculated_status = co.pull_request.calculated_review_status()
882 884 if old_calculated_status != calculated_status:
883 885 PullRequestModel()._trigger_pull_request_hook(
884 886 co.pull_request, c.rhodecode_user, 'review_status_change')
885 887 return True
886 888 else:
887 889 raise HTTPForbidden()
@@ -1,1309 +1,1314 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 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from sqlalchemy import or_
35 35
36 36 from rhodecode.lib import helpers as h, hooks_utils, diffs
37 37 from rhodecode.lib.compat import OrderedDict
38 38 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
39 39 from rhodecode.lib.markup_renderer import (
40 40 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
41 41 from rhodecode.lib.utils import action_logger
42 42 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
45 45 from rhodecode.lib.vcs.conf import settings as vcs_settings
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 CommitDoesNotExistError, EmptyRepositoryError)
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import ChangesetCommentsModel
51 51 from rhodecode.model.db import (
52 52 PullRequest, PullRequestReviewers, ChangesetStatus,
53 53 PullRequestVersion, ChangesetComment)
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.notification import NotificationModel, \
56 56 EmailNotificationModel
57 57 from rhodecode.model.scm import ScmModel
58 58 from rhodecode.model.settings import VcsSettingsModel
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 # Data structure to hold the response data when updating commits during a pull
65 65 # request update.
66 66 UpdateResponse = namedtuple(
67 67 'UpdateResponse', 'executed, reason, new, old, changes')
68 68
69 69
70 70 class PullRequestModel(BaseModel):
71 71
72 72 cls = PullRequest
73 73
74 74 DIFF_CONTEXT = 3
75 75
76 76 MERGE_STATUS_MESSAGES = {
77 77 MergeFailureReason.NONE: lazy_ugettext(
78 78 'This pull request can be automatically merged.'),
79 79 MergeFailureReason.UNKNOWN: lazy_ugettext(
80 80 'This pull request cannot be merged because of an unhandled'
81 81 ' exception.'),
82 82 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
83 83 'This pull request cannot be merged because of conflicts.'),
84 84 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
85 85 'This pull request could not be merged because push to target'
86 86 ' failed.'),
87 87 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
88 88 'This pull request cannot be merged because the target is not a'
89 89 ' head.'),
90 90 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
91 91 'This pull request cannot be merged because the source contains'
92 92 ' more branches than the target.'),
93 93 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
94 94 'This pull request cannot be merged because the target has'
95 95 ' multiple heads.'),
96 96 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
97 97 'This pull request cannot be merged because the target repository'
98 98 ' is locked.'),
99 99 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
100 100 'This pull request cannot be merged because the target or the '
101 101 'source reference is missing.'),
102 102 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
103 103 'This pull request cannot be merged because the target '
104 104 'reference is missing.'),
105 105 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the source '
107 107 'reference is missing.'),
108 108 }
109 109
110 110 UPDATE_STATUS_MESSAGES = {
111 111 UpdateFailureReason.NONE: lazy_ugettext(
112 112 'Pull request update successful.'),
113 113 UpdateFailureReason.UNKNOWN: lazy_ugettext(
114 114 'Pull request update failed because of an unknown error.'),
115 115 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
116 116 'No update needed because the source reference is already '
117 117 'up to date.'),
118 118 UpdateFailureReason.WRONG_REF_TPYE: lazy_ugettext(
119 119 'Pull request cannot be updated because the reference type is '
120 120 'not supported for an update.'),
121 121 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
122 122 'This pull request cannot be updated because the target '
123 123 'reference is missing.'),
124 124 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
125 125 'This pull request cannot be updated because the source '
126 126 'reference is missing.'),
127 127 }
128 128
129 129 def __get_pull_request(self, pull_request):
130 130 return self._get_instance(PullRequest, pull_request)
131 131
132 132 def _check_perms(self, perms, pull_request, user, api=False):
133 133 if not api:
134 134 return h.HasRepoPermissionAny(*perms)(
135 135 user=user, repo_name=pull_request.target_repo.repo_name)
136 136 else:
137 137 return h.HasRepoPermissionAnyApi(*perms)(
138 138 user=user, repo_name=pull_request.target_repo.repo_name)
139 139
140 140 def check_user_read(self, pull_request, user, api=False):
141 141 _perms = ('repository.admin', 'repository.write', 'repository.read',)
142 142 return self._check_perms(_perms, pull_request, user, api)
143 143
144 144 def check_user_merge(self, pull_request, user, api=False):
145 145 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
146 146 return self._check_perms(_perms, pull_request, user, api)
147 147
148 148 def check_user_update(self, pull_request, user, api=False):
149 149 owner = user.user_id == pull_request.user_id
150 150 return self.check_user_merge(pull_request, user, api) or owner
151 151
152 def check_user_delete(self, pull_request, user):
153 owner = user.user_id == pull_request.user_id
154 _perms = ('repository.admin')
155 return self._check_perms(_perms, pull_request, user) or owner
156
152 157 def check_user_change_status(self, pull_request, user, api=False):
153 158 reviewer = user.user_id in [x.user_id for x in
154 159 pull_request.reviewers]
155 160 return self.check_user_update(pull_request, user, api) or reviewer
156 161
157 162 def get(self, pull_request):
158 163 return self.__get_pull_request(pull_request)
159 164
160 165 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
161 166 opened_by=None, order_by=None,
162 167 order_dir='desc'):
163 168 repo = None
164 169 if repo_name:
165 170 repo = self._get_repo(repo_name)
166 171
167 172 q = PullRequest.query()
168 173
169 174 # source or target
170 175 if repo and source:
171 176 q = q.filter(PullRequest.source_repo == repo)
172 177 elif repo:
173 178 q = q.filter(PullRequest.target_repo == repo)
174 179
175 180 # closed,opened
176 181 if statuses:
177 182 q = q.filter(PullRequest.status.in_(statuses))
178 183
179 184 # opened by filter
180 185 if opened_by:
181 186 q = q.filter(PullRequest.user_id.in_(opened_by))
182 187
183 188 if order_by:
184 189 order_map = {
185 190 'name_raw': PullRequest.pull_request_id,
186 191 'title': PullRequest.title,
187 192 'updated_on_raw': PullRequest.updated_on,
188 193 'target_repo': PullRequest.target_repo_id
189 194 }
190 195 if order_dir == 'asc':
191 196 q = q.order_by(order_map[order_by].asc())
192 197 else:
193 198 q = q.order_by(order_map[order_by].desc())
194 199
195 200 return q
196 201
197 202 def count_all(self, repo_name, source=False, statuses=None,
198 203 opened_by=None):
199 204 """
200 205 Count the number of pull requests for a specific repository.
201 206
202 207 :param repo_name: target or source repo
203 208 :param source: boolean flag to specify if repo_name refers to source
204 209 :param statuses: list of pull request statuses
205 210 :param opened_by: author user of the pull request
206 211 :returns: int number of pull requests
207 212 """
208 213 q = self._prepare_get_all_query(
209 214 repo_name, source=source, statuses=statuses, opened_by=opened_by)
210 215
211 216 return q.count()
212 217
213 218 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
214 219 offset=0, length=None, order_by=None, order_dir='desc'):
215 220 """
216 221 Get all pull requests for a specific repository.
217 222
218 223 :param repo_name: target or source repo
219 224 :param source: boolean flag to specify if repo_name refers to source
220 225 :param statuses: list of pull request statuses
221 226 :param opened_by: author user of the pull request
222 227 :param offset: pagination offset
223 228 :param length: length of returned list
224 229 :param order_by: order of the returned list
225 230 :param order_dir: 'asc' or 'desc' ordering direction
226 231 :returns: list of pull requests
227 232 """
228 233 q = self._prepare_get_all_query(
229 234 repo_name, source=source, statuses=statuses, opened_by=opened_by,
230 235 order_by=order_by, order_dir=order_dir)
231 236
232 237 if length:
233 238 pull_requests = q.limit(length).offset(offset).all()
234 239 else:
235 240 pull_requests = q.all()
236 241
237 242 return pull_requests
238 243
239 244 def count_awaiting_review(self, repo_name, source=False, statuses=None,
240 245 opened_by=None):
241 246 """
242 247 Count the number of pull requests for a specific repository that are
243 248 awaiting review.
244 249
245 250 :param repo_name: target or source repo
246 251 :param source: boolean flag to specify if repo_name refers to source
247 252 :param statuses: list of pull request statuses
248 253 :param opened_by: author user of the pull request
249 254 :returns: int number of pull requests
250 255 """
251 256 pull_requests = self.get_awaiting_review(
252 257 repo_name, source=source, statuses=statuses, opened_by=opened_by)
253 258
254 259 return len(pull_requests)
255 260
256 261 def get_awaiting_review(self, repo_name, source=False, statuses=None,
257 262 opened_by=None, offset=0, length=None,
258 263 order_by=None, order_dir='desc'):
259 264 """
260 265 Get all pull requests for a specific repository that are awaiting
261 266 review.
262 267
263 268 :param repo_name: target or source repo
264 269 :param source: boolean flag to specify if repo_name refers to source
265 270 :param statuses: list of pull request statuses
266 271 :param opened_by: author user of the pull request
267 272 :param offset: pagination offset
268 273 :param length: length of returned list
269 274 :param order_by: order of the returned list
270 275 :param order_dir: 'asc' or 'desc' ordering direction
271 276 :returns: list of pull requests
272 277 """
273 278 pull_requests = self.get_all(
274 279 repo_name, source=source, statuses=statuses, opened_by=opened_by,
275 280 order_by=order_by, order_dir=order_dir)
276 281
277 282 _filtered_pull_requests = []
278 283 for pr in pull_requests:
279 284 status = pr.calculated_review_status()
280 285 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
281 286 ChangesetStatus.STATUS_UNDER_REVIEW]:
282 287 _filtered_pull_requests.append(pr)
283 288 if length:
284 289 return _filtered_pull_requests[offset:offset+length]
285 290 else:
286 291 return _filtered_pull_requests
287 292
288 293 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
289 294 opened_by=None, user_id=None):
290 295 """
291 296 Count the number of pull requests for a specific repository that are
292 297 awaiting review from a specific user.
293 298
294 299 :param repo_name: target or source repo
295 300 :param source: boolean flag to specify if repo_name refers to source
296 301 :param statuses: list of pull request statuses
297 302 :param opened_by: author user of the pull request
298 303 :param user_id: reviewer user of the pull request
299 304 :returns: int number of pull requests
300 305 """
301 306 pull_requests = self.get_awaiting_my_review(
302 307 repo_name, source=source, statuses=statuses, opened_by=opened_by,
303 308 user_id=user_id)
304 309
305 310 return len(pull_requests)
306 311
307 312 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
308 313 opened_by=None, user_id=None, offset=0,
309 314 length=None, order_by=None, order_dir='desc'):
310 315 """
311 316 Get all pull requests for a specific repository that are awaiting
312 317 review from a specific user.
313 318
314 319 :param repo_name: target or source repo
315 320 :param source: boolean flag to specify if repo_name refers to source
316 321 :param statuses: list of pull request statuses
317 322 :param opened_by: author user of the pull request
318 323 :param user_id: reviewer user of the pull request
319 324 :param offset: pagination offset
320 325 :param length: length of returned list
321 326 :param order_by: order of the returned list
322 327 :param order_dir: 'asc' or 'desc' ordering direction
323 328 :returns: list of pull requests
324 329 """
325 330 pull_requests = self.get_all(
326 331 repo_name, source=source, statuses=statuses, opened_by=opened_by,
327 332 order_by=order_by, order_dir=order_dir)
328 333
329 334 _my = PullRequestModel().get_not_reviewed(user_id)
330 335 my_participation = []
331 336 for pr in pull_requests:
332 337 if pr in _my:
333 338 my_participation.append(pr)
334 339 _filtered_pull_requests = my_participation
335 340 if length:
336 341 return _filtered_pull_requests[offset:offset+length]
337 342 else:
338 343 return _filtered_pull_requests
339 344
340 345 def get_not_reviewed(self, user_id):
341 346 return [
342 347 x.pull_request for x in PullRequestReviewers.query().filter(
343 348 PullRequestReviewers.user_id == user_id).all()
344 349 ]
345 350
346 351 def _prepare_participating_query(self, user_id=None, statuses=None,
347 352 order_by=None, order_dir='desc'):
348 353 q = PullRequest.query()
349 354 if user_id:
350 355 reviewers_subquery = Session().query(
351 356 PullRequestReviewers.pull_request_id).filter(
352 357 PullRequestReviewers.user_id == user_id).subquery()
353 358 user_filter= or_(
354 359 PullRequest.user_id == user_id,
355 360 PullRequest.pull_request_id.in_(reviewers_subquery)
356 361 )
357 362 q = PullRequest.query().filter(user_filter)
358 363
359 364 # closed,opened
360 365 if statuses:
361 366 q = q.filter(PullRequest.status.in_(statuses))
362 367
363 368 if order_by:
364 369 order_map = {
365 370 'name_raw': PullRequest.pull_request_id,
366 371 'title': PullRequest.title,
367 372 'updated_on_raw': PullRequest.updated_on,
368 373 'target_repo': PullRequest.target_repo_id
369 374 }
370 375 if order_dir == 'asc':
371 376 q = q.order_by(order_map[order_by].asc())
372 377 else:
373 378 q = q.order_by(order_map[order_by].desc())
374 379
375 380 return q
376 381
377 382 def count_im_participating_in(self, user_id=None, statuses=None):
378 383 q = self._prepare_participating_query(user_id, statuses=statuses)
379 384 return q.count()
380 385
381 386 def get_im_participating_in(
382 387 self, user_id=None, statuses=None, offset=0,
383 388 length=None, order_by=None, order_dir='desc'):
384 389 """
385 390 Get all Pull requests that i'm participating in, or i have opened
386 391 """
387 392
388 393 q = self._prepare_participating_query(
389 394 user_id, statuses=statuses, order_by=order_by,
390 395 order_dir=order_dir)
391 396
392 397 if length:
393 398 pull_requests = q.limit(length).offset(offset).all()
394 399 else:
395 400 pull_requests = q.all()
396 401
397 402 return pull_requests
398 403
399 404 def get_versions(self, pull_request):
400 405 """
401 406 returns version of pull request sorted by ID descending
402 407 """
403 408 return PullRequestVersion.query()\
404 409 .filter(PullRequestVersion.pull_request == pull_request)\
405 410 .order_by(PullRequestVersion.pull_request_version_id.asc())\
406 411 .all()
407 412
408 413 def create(self, created_by, source_repo, source_ref, target_repo,
409 414 target_ref, revisions, reviewers, title, description=None):
410 415 created_by_user = self._get_user(created_by)
411 416 source_repo = self._get_repo(source_repo)
412 417 target_repo = self._get_repo(target_repo)
413 418
414 419 pull_request = PullRequest()
415 420 pull_request.source_repo = source_repo
416 421 pull_request.source_ref = source_ref
417 422 pull_request.target_repo = target_repo
418 423 pull_request.target_ref = target_ref
419 424 pull_request.revisions = revisions
420 425 pull_request.title = title
421 426 pull_request.description = description
422 427 pull_request.author = created_by_user
423 428
424 429 Session().add(pull_request)
425 430 Session().flush()
426 431
427 432 reviewer_ids = set()
428 433 # members / reviewers
429 434 for reviewer_object in reviewers:
430 435 if isinstance(reviewer_object, tuple):
431 436 user_id, reasons = reviewer_object
432 437 else:
433 438 user_id, reasons = reviewer_object, []
434 439
435 440 user = self._get_user(user_id)
436 441 reviewer_ids.add(user.user_id)
437 442
438 443 reviewer = PullRequestReviewers(user, pull_request, reasons)
439 444 Session().add(reviewer)
440 445
441 446 # Set approval status to "Under Review" for all commits which are
442 447 # part of this pull request.
443 448 ChangesetStatusModel().set_status(
444 449 repo=target_repo,
445 450 status=ChangesetStatus.STATUS_UNDER_REVIEW,
446 451 user=created_by_user,
447 452 pull_request=pull_request
448 453 )
449 454
450 455 self.notify_reviewers(pull_request, reviewer_ids)
451 456 self._trigger_pull_request_hook(
452 457 pull_request, created_by_user, 'create')
453 458
454 459 return pull_request
455 460
456 461 def _trigger_pull_request_hook(self, pull_request, user, action):
457 462 pull_request = self.__get_pull_request(pull_request)
458 463 target_scm = pull_request.target_repo.scm_instance()
459 464 if action == 'create':
460 465 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
461 466 elif action == 'merge':
462 467 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
463 468 elif action == 'close':
464 469 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
465 470 elif action == 'review_status_change':
466 471 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
467 472 elif action == 'update':
468 473 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
469 474 else:
470 475 return
471 476
472 477 trigger_hook(
473 478 username=user.username,
474 479 repo_name=pull_request.target_repo.repo_name,
475 480 repo_alias=target_scm.alias,
476 481 pull_request=pull_request)
477 482
478 483 def _get_commit_ids(self, pull_request):
479 484 """
480 485 Return the commit ids of the merged pull request.
481 486
482 487 This method is not dealing correctly yet with the lack of autoupdates
483 488 nor with the implicit target updates.
484 489 For example: if a commit in the source repo is already in the target it
485 490 will be reported anyways.
486 491 """
487 492 merge_rev = pull_request.merge_rev
488 493 if merge_rev is None:
489 494 raise ValueError('This pull request was not merged yet')
490 495
491 496 commit_ids = list(pull_request.revisions)
492 497 if merge_rev not in commit_ids:
493 498 commit_ids.append(merge_rev)
494 499
495 500 return commit_ids
496 501
497 502 def merge(self, pull_request, user, extras):
498 503 log.debug("Merging pull request %s", pull_request.pull_request_id)
499 504 merge_state = self._merge_pull_request(pull_request, user, extras)
500 505 if merge_state.executed:
501 506 log.debug(
502 507 "Merge was successful, updating the pull request comments.")
503 508 self._comment_and_close_pr(pull_request, user, merge_state)
504 509 self._log_action('user_merged_pull_request', user, pull_request)
505 510 else:
506 511 log.warn("Merge failed, not updating the pull request.")
507 512 return merge_state
508 513
509 514 def _merge_pull_request(self, pull_request, user, extras):
510 515 target_vcs = pull_request.target_repo.scm_instance()
511 516 source_vcs = pull_request.source_repo.scm_instance()
512 517 target_ref = self._refresh_reference(
513 518 pull_request.target_ref_parts, target_vcs)
514 519
515 520 message = _(
516 521 'Merge pull request #%(pr_id)s from '
517 522 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
518 523 'pr_id': pull_request.pull_request_id,
519 524 'source_repo': source_vcs.name,
520 525 'source_ref_name': pull_request.source_ref_parts.name,
521 526 'pr_title': pull_request.title
522 527 }
523 528
524 529 workspace_id = self._workspace_id(pull_request)
525 530 use_rebase = self._use_rebase_for_merging(pull_request)
526 531
527 532 callback_daemon, extras = prepare_callback_daemon(
528 533 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
529 534 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
530 535
531 536 with callback_daemon:
532 537 # TODO: johbo: Implement a clean way to run a config_override
533 538 # for a single call.
534 539 target_vcs.config.set(
535 540 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
536 541 merge_state = target_vcs.merge(
537 542 target_ref, source_vcs, pull_request.source_ref_parts,
538 543 workspace_id, user_name=user.username,
539 544 user_email=user.email, message=message, use_rebase=use_rebase)
540 545 return merge_state
541 546
542 547 def _comment_and_close_pr(self, pull_request, user, merge_state):
543 548 pull_request.merge_rev = merge_state.merge_ref.commit_id
544 549 pull_request.updated_on = datetime.datetime.now()
545 550
546 551 ChangesetCommentsModel().create(
547 552 text=unicode(_('Pull request merged and closed')),
548 553 repo=pull_request.target_repo.repo_id,
549 554 user=user.user_id,
550 555 pull_request=pull_request.pull_request_id,
551 556 f_path=None,
552 557 line_no=None,
553 558 closing_pr=True
554 559 )
555 560
556 561 Session().add(pull_request)
557 562 Session().flush()
558 563 # TODO: paris: replace invalidation with less radical solution
559 564 ScmModel().mark_for_invalidation(
560 565 pull_request.target_repo.repo_name)
561 566 self._trigger_pull_request_hook(pull_request, user, 'merge')
562 567
563 568 def has_valid_update_type(self, pull_request):
564 569 source_ref_type = pull_request.source_ref_parts.type
565 570 return source_ref_type in ['book', 'branch', 'tag']
566 571
567 572 def update_commits(self, pull_request):
568 573 """
569 574 Get the updated list of commits for the pull request
570 575 and return the new pull request version and the list
571 576 of commits processed by this update action
572 577 """
573 578 pull_request = self.__get_pull_request(pull_request)
574 579 source_ref_type = pull_request.source_ref_parts.type
575 580 source_ref_name = pull_request.source_ref_parts.name
576 581 source_ref_id = pull_request.source_ref_parts.commit_id
577 582
578 583 if not self.has_valid_update_type(pull_request):
579 584 log.debug(
580 585 "Skipping update of pull request %s due to ref type: %s",
581 586 pull_request, source_ref_type)
582 587 return UpdateResponse(
583 588 executed=False,
584 589 reason=UpdateFailureReason.WRONG_REF_TPYE,
585 590 old=pull_request, new=None, changes=None)
586 591
587 592 source_repo = pull_request.source_repo.scm_instance()
588 593 try:
589 594 source_commit = source_repo.get_commit(commit_id=source_ref_name)
590 595 except CommitDoesNotExistError:
591 596 return UpdateResponse(
592 597 executed=False,
593 598 reason=UpdateFailureReason.MISSING_SOURCE_REF,
594 599 old=pull_request, new=None, changes=None)
595 600
596 601 if source_ref_id == source_commit.raw_id:
597 602 log.debug("Nothing changed in pull request %s", pull_request)
598 603 return UpdateResponse(
599 604 executed=False,
600 605 reason=UpdateFailureReason.NO_CHANGE,
601 606 old=pull_request, new=None, changes=None)
602 607
603 608 # Finally there is a need for an update
604 609 pull_request_version = self._create_version_from_snapshot(pull_request)
605 610 self._link_comments_to_version(pull_request_version)
606 611
607 612 target_ref_type = pull_request.target_ref_parts.type
608 613 target_ref_name = pull_request.target_ref_parts.name
609 614 target_ref_id = pull_request.target_ref_parts.commit_id
610 615 target_repo = pull_request.target_repo.scm_instance()
611 616
612 617 try:
613 618 if target_ref_type in ('tag', 'branch', 'book'):
614 619 target_commit = target_repo.get_commit(target_ref_name)
615 620 else:
616 621 target_commit = target_repo.get_commit(target_ref_id)
617 622 except CommitDoesNotExistError:
618 623 return UpdateResponse(
619 624 executed=False,
620 625 reason=UpdateFailureReason.MISSING_TARGET_REF,
621 626 old=pull_request, new=None, changes=None)
622 627
623 628 # re-compute commit ids
624 629 old_commit_ids = set(pull_request.revisions)
625 630 pre_load = ["author", "branch", "date", "message"]
626 631 commit_ranges = target_repo.compare(
627 632 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
628 633 pre_load=pre_load)
629 634
630 635 ancestor = target_repo.get_common_ancestor(
631 636 target_commit.raw_id, source_commit.raw_id, source_repo)
632 637
633 638 pull_request.source_ref = '%s:%s:%s' % (
634 639 source_ref_type, source_ref_name, source_commit.raw_id)
635 640 pull_request.target_ref = '%s:%s:%s' % (
636 641 target_ref_type, target_ref_name, ancestor)
637 642 pull_request.revisions = [
638 643 commit.raw_id for commit in reversed(commit_ranges)]
639 644 pull_request.updated_on = datetime.datetime.now()
640 645 Session().add(pull_request)
641 646 new_commit_ids = set(pull_request.revisions)
642 647
643 648 changes = self._calculate_commit_id_changes(
644 649 old_commit_ids, new_commit_ids)
645 650
646 651 old_diff_data, new_diff_data = self._generate_update_diffs(
647 652 pull_request, pull_request_version)
648 653
649 654 ChangesetCommentsModel().outdate_comments(
650 655 pull_request, old_diff_data=old_diff_data,
651 656 new_diff_data=new_diff_data)
652 657
653 658 file_changes = self._calculate_file_changes(
654 659 old_diff_data, new_diff_data)
655 660
656 661 # Add an automatic comment to the pull request
657 662 update_comment = ChangesetCommentsModel().create(
658 663 text=self._render_update_message(changes, file_changes),
659 664 repo=pull_request.target_repo,
660 665 user=pull_request.author,
661 666 pull_request=pull_request,
662 667 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
663 668
664 669 # Update status to "Under Review" for added commits
665 670 for commit_id in changes.added:
666 671 ChangesetStatusModel().set_status(
667 672 repo=pull_request.source_repo,
668 673 status=ChangesetStatus.STATUS_UNDER_REVIEW,
669 674 comment=update_comment,
670 675 user=pull_request.author,
671 676 pull_request=pull_request,
672 677 revision=commit_id)
673 678
674 679 log.debug(
675 680 'Updated pull request %s, added_ids: %s, common_ids: %s, '
676 681 'removed_ids: %s', pull_request.pull_request_id,
677 682 changes.added, changes.common, changes.removed)
678 683 log.debug('Updated pull request with the following file changes: %s',
679 684 file_changes)
680 685
681 686 log.info(
682 687 "Updated pull request %s from commit %s to commit %s, "
683 688 "stored new version %s of this pull request.",
684 689 pull_request.pull_request_id, source_ref_id,
685 690 pull_request.source_ref_parts.commit_id,
686 691 pull_request_version.pull_request_version_id)
687 692 Session().commit()
688 693 self._trigger_pull_request_hook(pull_request, pull_request.author,
689 694 'update')
690 695
691 696 return UpdateResponse(
692 697 executed=True, reason=UpdateFailureReason.NONE,
693 698 old=pull_request, new=pull_request_version, changes=changes)
694 699
695 700 def _create_version_from_snapshot(self, pull_request):
696 701 version = PullRequestVersion()
697 702 version.title = pull_request.title
698 703 version.description = pull_request.description
699 704 version.status = pull_request.status
700 705 version.created_on = pull_request.created_on
701 706 version.updated_on = pull_request.updated_on
702 707 version.user_id = pull_request.user_id
703 708 version.source_repo = pull_request.source_repo
704 709 version.source_ref = pull_request.source_ref
705 710 version.target_repo = pull_request.target_repo
706 711 version.target_ref = pull_request.target_ref
707 712
708 713 version._last_merge_source_rev = pull_request._last_merge_source_rev
709 714 version._last_merge_target_rev = pull_request._last_merge_target_rev
710 715 version._last_merge_status = pull_request._last_merge_status
711 716 version.shadow_merge_ref = pull_request.shadow_merge_ref
712 717 version.merge_rev = pull_request.merge_rev
713 718
714 719 version.revisions = pull_request.revisions
715 720 version.pull_request = pull_request
716 721 Session().add(version)
717 722 Session().flush()
718 723
719 724 return version
720 725
721 726 def _generate_update_diffs(self, pull_request, pull_request_version):
722 727 diff_context = (
723 728 self.DIFF_CONTEXT +
724 729 ChangesetCommentsModel.needed_extra_diff_context())
725 730 old_diff = self._get_diff_from_pr_or_version(
726 731 pull_request_version, context=diff_context)
727 732 new_diff = self._get_diff_from_pr_or_version(
728 733 pull_request, context=diff_context)
729 734
730 735 old_diff_data = diffs.DiffProcessor(old_diff)
731 736 old_diff_data.prepare()
732 737 new_diff_data = diffs.DiffProcessor(new_diff)
733 738 new_diff_data.prepare()
734 739
735 740 return old_diff_data, new_diff_data
736 741
737 742 def _link_comments_to_version(self, pull_request_version):
738 743 """
739 744 Link all unlinked comments of this pull request to the given version.
740 745
741 746 :param pull_request_version: The `PullRequestVersion` to which
742 747 the comments shall be linked.
743 748
744 749 """
745 750 pull_request = pull_request_version.pull_request
746 751 comments = ChangesetComment.query().filter(
747 752 # TODO: johbo: Should we query for the repo at all here?
748 753 # Pending decision on how comments of PRs are to be related
749 754 # to either the source repo, the target repo or no repo at all.
750 755 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
751 756 ChangesetComment.pull_request == pull_request,
752 757 ChangesetComment.pull_request_version == None)
753 758
754 759 # TODO: johbo: Find out why this breaks if it is done in a bulk
755 760 # operation.
756 761 for comment in comments:
757 762 comment.pull_request_version_id = (
758 763 pull_request_version.pull_request_version_id)
759 764 Session().add(comment)
760 765
761 766 def _calculate_commit_id_changes(self, old_ids, new_ids):
762 767 added = new_ids.difference(old_ids)
763 768 common = old_ids.intersection(new_ids)
764 769 removed = old_ids.difference(new_ids)
765 770 return ChangeTuple(added, common, removed)
766 771
767 772 def _calculate_file_changes(self, old_diff_data, new_diff_data):
768 773
769 774 old_files = OrderedDict()
770 775 for diff_data in old_diff_data.parsed_diff:
771 776 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
772 777
773 778 added_files = []
774 779 modified_files = []
775 780 removed_files = []
776 781 for diff_data in new_diff_data.parsed_diff:
777 782 new_filename = diff_data['filename']
778 783 new_hash = md5_safe(diff_data['raw_diff'])
779 784
780 785 old_hash = old_files.get(new_filename)
781 786 if not old_hash:
782 787 # file is not present in old diff, means it's added
783 788 added_files.append(new_filename)
784 789 else:
785 790 if new_hash != old_hash:
786 791 modified_files.append(new_filename)
787 792 # now remove a file from old, since we have seen it already
788 793 del old_files[new_filename]
789 794
790 795 # removed files is when there are present in old, but not in NEW,
791 796 # since we remove old files that are present in new diff, left-overs
792 797 # if any should be the removed files
793 798 removed_files.extend(old_files.keys())
794 799
795 800 return FileChangeTuple(added_files, modified_files, removed_files)
796 801
797 802 def _render_update_message(self, changes, file_changes):
798 803 """
799 804 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
800 805 so it's always looking the same disregarding on which default
801 806 renderer system is using.
802 807
803 808 :param changes: changes named tuple
804 809 :param file_changes: file changes named tuple
805 810
806 811 """
807 812 new_status = ChangesetStatus.get_status_lbl(
808 813 ChangesetStatus.STATUS_UNDER_REVIEW)
809 814
810 815 changed_files = (
811 816 file_changes.added + file_changes.modified + file_changes.removed)
812 817
813 818 params = {
814 819 'under_review_label': new_status,
815 820 'added_commits': changes.added,
816 821 'removed_commits': changes.removed,
817 822 'changed_files': changed_files,
818 823 'added_files': file_changes.added,
819 824 'modified_files': file_changes.modified,
820 825 'removed_files': file_changes.removed,
821 826 }
822 827 renderer = RstTemplateRenderer()
823 828 return renderer.render('pull_request_update.mako', **params)
824 829
825 830 def edit(self, pull_request, title, description):
826 831 pull_request = self.__get_pull_request(pull_request)
827 832 if pull_request.is_closed():
828 833 raise ValueError('This pull request is closed')
829 834 if title:
830 835 pull_request.title = title
831 836 pull_request.description = description
832 837 pull_request.updated_on = datetime.datetime.now()
833 838 Session().add(pull_request)
834 839
835 840 def update_reviewers(self, pull_request, reviewer_data):
836 841 """
837 842 Update the reviewers in the pull request
838 843
839 844 :param pull_request: the pr to update
840 845 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
841 846 """
842 847
843 848 reviewers_reasons = {}
844 849 for user_id, reasons in reviewer_data:
845 850 if isinstance(user_id, (int, basestring)):
846 851 user_id = self._get_user(user_id).user_id
847 852 reviewers_reasons[user_id] = reasons
848 853
849 854 reviewers_ids = set(reviewers_reasons.keys())
850 855 pull_request = self.__get_pull_request(pull_request)
851 856 current_reviewers = PullRequestReviewers.query()\
852 857 .filter(PullRequestReviewers.pull_request ==
853 858 pull_request).all()
854 859 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
855 860
856 861 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
857 862 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
858 863
859 864 log.debug("Adding %s reviewers", ids_to_add)
860 865 log.debug("Removing %s reviewers", ids_to_remove)
861 866 changed = False
862 867 for uid in ids_to_add:
863 868 changed = True
864 869 _usr = self._get_user(uid)
865 870 reasons = reviewers_reasons[uid]
866 871 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
867 872 Session().add(reviewer)
868 873
869 874 self.notify_reviewers(pull_request, ids_to_add)
870 875
871 876 for uid in ids_to_remove:
872 877 changed = True
873 878 reviewer = PullRequestReviewers.query()\
874 879 .filter(PullRequestReviewers.user_id == uid,
875 880 PullRequestReviewers.pull_request == pull_request)\
876 881 .scalar()
877 882 if reviewer:
878 883 Session().delete(reviewer)
879 884 if changed:
880 885 pull_request.updated_on = datetime.datetime.now()
881 886 Session().add(pull_request)
882 887
883 888 return ids_to_add, ids_to_remove
884 889
885 890 def get_url(self, pull_request):
886 891 return h.url('pullrequest_show',
887 892 repo_name=safe_str(pull_request.target_repo.repo_name),
888 893 pull_request_id=pull_request.pull_request_id,
889 894 qualified=True)
890 895
891 896 def get_shadow_clone_url(self, pull_request):
892 897 """
893 898 Returns qualified url pointing to the shadow repository. If this pull
894 899 request is closed there is no shadow repository and ``None`` will be
895 900 returned.
896 901 """
897 902 if pull_request.is_closed():
898 903 return None
899 904 else:
900 905 pr_url = urllib.unquote(self.get_url(pull_request))
901 906 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
902 907
903 908 def notify_reviewers(self, pull_request, reviewers_ids):
904 909 # notification to reviewers
905 910 if not reviewers_ids:
906 911 return
907 912
908 913 pull_request_obj = pull_request
909 914 # get the current participants of this pull request
910 915 recipients = reviewers_ids
911 916 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
912 917
913 918 pr_source_repo = pull_request_obj.source_repo
914 919 pr_target_repo = pull_request_obj.target_repo
915 920
916 921 pr_url = h.url(
917 922 'pullrequest_show',
918 923 repo_name=pr_target_repo.repo_name,
919 924 pull_request_id=pull_request_obj.pull_request_id,
920 925 qualified=True,)
921 926
922 927 # set some variables for email notification
923 928 pr_target_repo_url = h.url(
924 929 'summary_home',
925 930 repo_name=pr_target_repo.repo_name,
926 931 qualified=True)
927 932
928 933 pr_source_repo_url = h.url(
929 934 'summary_home',
930 935 repo_name=pr_source_repo.repo_name,
931 936 qualified=True)
932 937
933 938 # pull request specifics
934 939 pull_request_commits = [
935 940 (x.raw_id, x.message)
936 941 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
937 942
938 943 kwargs = {
939 944 'user': pull_request.author,
940 945 'pull_request': pull_request_obj,
941 946 'pull_request_commits': pull_request_commits,
942 947
943 948 'pull_request_target_repo': pr_target_repo,
944 949 'pull_request_target_repo_url': pr_target_repo_url,
945 950
946 951 'pull_request_source_repo': pr_source_repo,
947 952 'pull_request_source_repo_url': pr_source_repo_url,
948 953
949 954 'pull_request_url': pr_url,
950 955 }
951 956
952 957 # pre-generate the subject for notification itself
953 958 (subject,
954 959 _h, _e, # we don't care about those
955 960 body_plaintext) = EmailNotificationModel().render_email(
956 961 notification_type, **kwargs)
957 962
958 963 # create notification objects, and emails
959 964 NotificationModel().create(
960 965 created_by=pull_request.author,
961 966 notification_subject=subject,
962 967 notification_body=body_plaintext,
963 968 notification_type=notification_type,
964 969 recipients=recipients,
965 970 email_kwargs=kwargs,
966 971 )
967 972
968 973 def delete(self, pull_request):
969 974 pull_request = self.__get_pull_request(pull_request)
970 975 self._cleanup_merge_workspace(pull_request)
971 976 Session().delete(pull_request)
972 977
973 978 def close_pull_request(self, pull_request, user):
974 979 pull_request = self.__get_pull_request(pull_request)
975 980 self._cleanup_merge_workspace(pull_request)
976 981 pull_request.status = PullRequest.STATUS_CLOSED
977 982 pull_request.updated_on = datetime.datetime.now()
978 983 Session().add(pull_request)
979 984 self._trigger_pull_request_hook(
980 985 pull_request, pull_request.author, 'close')
981 986 self._log_action('user_closed_pull_request', user, pull_request)
982 987
983 988 def close_pull_request_with_comment(self, pull_request, user, repo,
984 989 message=None):
985 990 status = ChangesetStatus.STATUS_REJECTED
986 991
987 992 if not message:
988 993 message = (
989 994 _('Status change %(transition_icon)s %(status)s') % {
990 995 'transition_icon': '>',
991 996 'status': ChangesetStatus.get_status_lbl(status)})
992 997
993 998 internal_message = _('Closing with') + ' ' + message
994 999
995 1000 comm = ChangesetCommentsModel().create(
996 1001 text=internal_message,
997 1002 repo=repo.repo_id,
998 1003 user=user.user_id,
999 1004 pull_request=pull_request.pull_request_id,
1000 1005 f_path=None,
1001 1006 line_no=None,
1002 1007 status_change=ChangesetStatus.get_status_lbl(status),
1003 1008 status_change_type=status,
1004 1009 closing_pr=True
1005 1010 )
1006 1011
1007 1012 ChangesetStatusModel().set_status(
1008 1013 repo.repo_id,
1009 1014 status,
1010 1015 user.user_id,
1011 1016 comm,
1012 1017 pull_request=pull_request.pull_request_id
1013 1018 )
1014 1019 Session().flush()
1015 1020
1016 1021 PullRequestModel().close_pull_request(
1017 1022 pull_request.pull_request_id, user)
1018 1023
1019 1024 def merge_status(self, pull_request):
1020 1025 if not self._is_merge_enabled(pull_request):
1021 1026 return False, _('Server-side pull request merging is disabled.')
1022 1027 if pull_request.is_closed():
1023 1028 return False, _('This pull request is closed.')
1024 1029 merge_possible, msg = self._check_repo_requirements(
1025 1030 target=pull_request.target_repo, source=pull_request.source_repo)
1026 1031 if not merge_possible:
1027 1032 return merge_possible, msg
1028 1033
1029 1034 try:
1030 1035 resp = self._try_merge(pull_request)
1031 1036 log.debug("Merge response: %s", resp)
1032 1037 status = resp.possible, self.merge_status_message(
1033 1038 resp.failure_reason)
1034 1039 except NotImplementedError:
1035 1040 status = False, _('Pull request merging is not supported.')
1036 1041
1037 1042 return status
1038 1043
1039 1044 def _check_repo_requirements(self, target, source):
1040 1045 """
1041 1046 Check if `target` and `source` have compatible requirements.
1042 1047
1043 1048 Currently this is just checking for largefiles.
1044 1049 """
1045 1050 target_has_largefiles = self._has_largefiles(target)
1046 1051 source_has_largefiles = self._has_largefiles(source)
1047 1052 merge_possible = True
1048 1053 message = u''
1049 1054
1050 1055 if target_has_largefiles != source_has_largefiles:
1051 1056 merge_possible = False
1052 1057 if source_has_largefiles:
1053 1058 message = _(
1054 1059 'Target repository large files support is disabled.')
1055 1060 else:
1056 1061 message = _(
1057 1062 'Source repository large files support is disabled.')
1058 1063
1059 1064 return merge_possible, message
1060 1065
1061 1066 def _has_largefiles(self, repo):
1062 1067 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1063 1068 'extensions', 'largefiles')
1064 1069 return largefiles_ui and largefiles_ui[0].active
1065 1070
1066 1071 def _try_merge(self, pull_request):
1067 1072 """
1068 1073 Try to merge the pull request and return the merge status.
1069 1074 """
1070 1075 log.debug(
1071 1076 "Trying out if the pull request %s can be merged.",
1072 1077 pull_request.pull_request_id)
1073 1078 target_vcs = pull_request.target_repo.scm_instance()
1074 1079
1075 1080 # Refresh the target reference.
1076 1081 try:
1077 1082 target_ref = self._refresh_reference(
1078 1083 pull_request.target_ref_parts, target_vcs)
1079 1084 except CommitDoesNotExistError:
1080 1085 merge_state = MergeResponse(
1081 1086 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1082 1087 return merge_state
1083 1088
1084 1089 target_locked = pull_request.target_repo.locked
1085 1090 if target_locked and target_locked[0]:
1086 1091 log.debug("The target repository is locked.")
1087 1092 merge_state = MergeResponse(
1088 1093 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1089 1094 elif self._needs_merge_state_refresh(pull_request, target_ref):
1090 1095 log.debug("Refreshing the merge status of the repository.")
1091 1096 merge_state = self._refresh_merge_state(
1092 1097 pull_request, target_vcs, target_ref)
1093 1098 else:
1094 1099 possible = pull_request.\
1095 1100 _last_merge_status == MergeFailureReason.NONE
1096 1101 merge_state = MergeResponse(
1097 1102 possible, False, None, pull_request._last_merge_status)
1098 1103
1099 1104 return merge_state
1100 1105
1101 1106 def _refresh_reference(self, reference, vcs_repository):
1102 1107 if reference.type in ('branch', 'book'):
1103 1108 name_or_id = reference.name
1104 1109 else:
1105 1110 name_or_id = reference.commit_id
1106 1111 refreshed_commit = vcs_repository.get_commit(name_or_id)
1107 1112 refreshed_reference = Reference(
1108 1113 reference.type, reference.name, refreshed_commit.raw_id)
1109 1114 return refreshed_reference
1110 1115
1111 1116 def _needs_merge_state_refresh(self, pull_request, target_reference):
1112 1117 return not(
1113 1118 pull_request.revisions and
1114 1119 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1115 1120 target_reference.commit_id == pull_request._last_merge_target_rev)
1116 1121
1117 1122 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1118 1123 workspace_id = self._workspace_id(pull_request)
1119 1124 source_vcs = pull_request.source_repo.scm_instance()
1120 1125 use_rebase = self._use_rebase_for_merging(pull_request)
1121 1126 merge_state = target_vcs.merge(
1122 1127 target_reference, source_vcs, pull_request.source_ref_parts,
1123 1128 workspace_id, dry_run=True, use_rebase=use_rebase)
1124 1129
1125 1130 # Do not store the response if there was an unknown error.
1126 1131 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1127 1132 pull_request._last_merge_source_rev = \
1128 1133 pull_request.source_ref_parts.commit_id
1129 1134 pull_request._last_merge_target_rev = target_reference.commit_id
1130 1135 pull_request._last_merge_status = merge_state.failure_reason
1131 1136 pull_request.shadow_merge_ref = merge_state.merge_ref
1132 1137 Session().add(pull_request)
1133 1138 Session().commit()
1134 1139
1135 1140 return merge_state
1136 1141
1137 1142 def _workspace_id(self, pull_request):
1138 1143 workspace_id = 'pr-%s' % pull_request.pull_request_id
1139 1144 return workspace_id
1140 1145
1141 1146 def merge_status_message(self, status_code):
1142 1147 """
1143 1148 Return a human friendly error message for the given merge status code.
1144 1149 """
1145 1150 return self.MERGE_STATUS_MESSAGES[status_code]
1146 1151
1147 1152 def generate_repo_data(self, repo, commit_id=None, branch=None,
1148 1153 bookmark=None):
1149 1154 all_refs, selected_ref = \
1150 1155 self._get_repo_pullrequest_sources(
1151 1156 repo.scm_instance(), commit_id=commit_id,
1152 1157 branch=branch, bookmark=bookmark)
1153 1158
1154 1159 refs_select2 = []
1155 1160 for element in all_refs:
1156 1161 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1157 1162 refs_select2.append({'text': element[1], 'children': children})
1158 1163
1159 1164 return {
1160 1165 'user': {
1161 1166 'user_id': repo.user.user_id,
1162 1167 'username': repo.user.username,
1163 1168 'firstname': repo.user.firstname,
1164 1169 'lastname': repo.user.lastname,
1165 1170 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1166 1171 },
1167 1172 'description': h.chop_at_smart(repo.description, '\n'),
1168 1173 'refs': {
1169 1174 'all_refs': all_refs,
1170 1175 'selected_ref': selected_ref,
1171 1176 'select2_refs': refs_select2
1172 1177 }
1173 1178 }
1174 1179
1175 1180 def generate_pullrequest_title(self, source, source_ref, target):
1176 1181 return u'{source}#{at_ref} to {target}'.format(
1177 1182 source=source,
1178 1183 at_ref=source_ref,
1179 1184 target=target,
1180 1185 )
1181 1186
1182 1187 def _cleanup_merge_workspace(self, pull_request):
1183 1188 # Merging related cleanup
1184 1189 target_scm = pull_request.target_repo.scm_instance()
1185 1190 workspace_id = 'pr-%s' % pull_request.pull_request_id
1186 1191
1187 1192 try:
1188 1193 target_scm.cleanup_merge_workspace(workspace_id)
1189 1194 except NotImplementedError:
1190 1195 pass
1191 1196
1192 1197 def _get_repo_pullrequest_sources(
1193 1198 self, repo, commit_id=None, branch=None, bookmark=None):
1194 1199 """
1195 1200 Return a structure with repo's interesting commits, suitable for
1196 1201 the selectors in pullrequest controller
1197 1202
1198 1203 :param commit_id: a commit that must be in the list somehow
1199 1204 and selected by default
1200 1205 :param branch: a branch that must be in the list and selected
1201 1206 by default - even if closed
1202 1207 :param bookmark: a bookmark that must be in the list and selected
1203 1208 """
1204 1209
1205 1210 commit_id = safe_str(commit_id) if commit_id else None
1206 1211 branch = safe_str(branch) if branch else None
1207 1212 bookmark = safe_str(bookmark) if bookmark else None
1208 1213
1209 1214 selected = None
1210 1215
1211 1216 # order matters: first source that has commit_id in it will be selected
1212 1217 sources = []
1213 1218 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1214 1219 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1215 1220
1216 1221 if commit_id:
1217 1222 ref_commit = (h.short_id(commit_id), commit_id)
1218 1223 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1219 1224
1220 1225 sources.append(
1221 1226 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1222 1227 )
1223 1228
1224 1229 groups = []
1225 1230 for group_key, ref_list, group_name, match in sources:
1226 1231 group_refs = []
1227 1232 for ref_name, ref_id in ref_list:
1228 1233 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1229 1234 group_refs.append((ref_key, ref_name))
1230 1235
1231 1236 if not selected:
1232 1237 if set([commit_id, match]) & set([ref_id, ref_name]):
1233 1238 selected = ref_key
1234 1239
1235 1240 if group_refs:
1236 1241 groups.append((group_refs, group_name))
1237 1242
1238 1243 if not selected:
1239 1244 ref = commit_id or branch or bookmark
1240 1245 if ref:
1241 1246 raise CommitDoesNotExistError(
1242 1247 'No commit refs could be found matching: %s' % ref)
1243 1248 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1244 1249 selected = 'branch:%s:%s' % (
1245 1250 repo.DEFAULT_BRANCH_NAME,
1246 1251 repo.branches[repo.DEFAULT_BRANCH_NAME]
1247 1252 )
1248 1253 elif repo.commit_ids:
1249 1254 rev = repo.commit_ids[0]
1250 1255 selected = 'rev:%s:%s' % (rev, rev)
1251 1256 else:
1252 1257 raise EmptyRepositoryError()
1253 1258 return groups, selected
1254 1259
1255 1260 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1256 1261 pull_request = self.__get_pull_request(pull_request)
1257 1262 return self._get_diff_from_pr_or_version(pull_request, context=context)
1258 1263
1259 1264 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1260 1265 source_repo = pr_or_version.source_repo
1261 1266
1262 1267 # we swap org/other ref since we run a simple diff on one repo
1263 1268 target_ref_id = pr_or_version.target_ref_parts.commit_id
1264 1269 source_ref_id = pr_or_version.source_ref_parts.commit_id
1265 1270 target_commit = source_repo.get_commit(
1266 1271 commit_id=safe_str(target_ref_id))
1267 1272 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1268 1273 vcs_repo = source_repo.scm_instance()
1269 1274
1270 1275 # TODO: johbo: In the context of an update, we cannot reach
1271 1276 # the old commit anymore with our normal mechanisms. It needs
1272 1277 # some sort of special support in the vcs layer to avoid this
1273 1278 # workaround.
1274 1279 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1275 1280 vcs_repo.alias == 'git'):
1276 1281 source_commit.raw_id = safe_str(source_ref_id)
1277 1282
1278 1283 log.debug('calculating diff between '
1279 1284 'source_ref:%s and target_ref:%s for repo `%s`',
1280 1285 target_ref_id, source_ref_id,
1281 1286 safe_unicode(vcs_repo.path))
1282 1287
1283 1288 vcs_diff = vcs_repo.get_diff(
1284 1289 commit1=target_commit, commit2=source_commit, context=context)
1285 1290 return vcs_diff
1286 1291
1287 1292 def _is_merge_enabled(self, pull_request):
1288 1293 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1289 1294 settings = settings_model.get_general_settings()
1290 1295 return settings.get('rhodecode_pr_merge_enabled', False)
1291 1296
1292 1297 def _use_rebase_for_merging(self, pull_request):
1293 1298 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1294 1299 settings = settings_model.get_general_settings()
1295 1300 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1296 1301
1297 1302 def _log_action(self, action, user, pull_request):
1298 1303 action_logger(
1299 1304 user,
1300 1305 '{action}:{pr_id}'.format(
1301 1306 action=action, pr_id=pull_request.pull_request_id),
1302 1307 pull_request.target_repo)
1303 1308
1304 1309
1305 1310 ChangeTuple = namedtuple('ChangeTuple',
1306 1311 ['added', 'common', 'removed'])
1307 1312
1308 1313 FileChangeTuple = namedtuple('FileChangeTuple',
1309 1314 ['added', 'modified', 'removed'])
@@ -1,2179 +1,2184 b''
1 1 //Primary CSS
2 2
3 3 //--- IMPORTS ------------------//
4 4
5 5 @import 'helpers';
6 6 @import 'mixins';
7 7 @import 'rcicons';
8 8 @import 'fonts';
9 9 @import 'variables';
10 10 @import 'bootstrap-variables';
11 11 @import 'form-bootstrap';
12 12 @import 'codemirror';
13 13 @import 'legacy_code_styles';
14 14 @import 'progress-bar';
15 15
16 16 @import 'type';
17 17 @import 'alerts';
18 18 @import 'buttons';
19 19 @import 'tags';
20 20 @import 'code-block';
21 21 @import 'examples';
22 22 @import 'login';
23 23 @import 'main-content';
24 24 @import 'select2';
25 25 @import 'comments';
26 26 @import 'panels-bootstrap';
27 27 @import 'panels';
28 28 @import 'deform';
29 29
30 30 //--- BASE ------------------//
31 31 .noscript-error {
32 32 top: 0;
33 33 left: 0;
34 34 width: 100%;
35 35 z-index: 101;
36 36 text-align: center;
37 37 font-family: @text-semibold;
38 38 font-size: 120%;
39 39 color: white;
40 40 background-color: @alert2;
41 41 padding: 5px 0 5px 0;
42 42 }
43 43
44 44 html {
45 45 display: table;
46 46 height: 100%;
47 47 width: 100%;
48 48 }
49 49
50 50 body {
51 51 display: table-cell;
52 52 width: 100%;
53 53 }
54 54
55 55 //--- LAYOUT ------------------//
56 56
57 57 .hidden{
58 58 display: none !important;
59 59 }
60 60
61 61 .box{
62 62 float: left;
63 63 width: 100%;
64 64 }
65 65
66 66 .browser-header {
67 67 clear: both;
68 68 }
69 69 .main {
70 70 clear: both;
71 71 padding:0 0 @pagepadding;
72 72 height: auto;
73 73
74 74 &:after { //clearfix
75 75 content:"";
76 76 clear:both;
77 77 width:100%;
78 78 display:block;
79 79 }
80 80 }
81 81
82 82 .action-link{
83 83 margin-left: @padding;
84 84 padding-left: @padding;
85 85 border-left: @border-thickness solid @border-default-color;
86 86 }
87 87
88 88 input + .action-link, .action-link.first{
89 89 border-left: none;
90 90 }
91 91
92 92 .action-link.last{
93 93 margin-right: @padding;
94 94 padding-right: @padding;
95 95 }
96 96
97 97 .action-link.active,
98 98 .action-link.active a{
99 99 color: @grey4;
100 100 }
101 101
102 102 ul.simple-list{
103 103 list-style: none;
104 104 margin: 0;
105 105 padding: 0;
106 106 }
107 107
108 108 .main-content {
109 109 padding-bottom: @pagepadding;
110 110 }
111 111
112 112 .wrapper {
113 113 position: relative;
114 114 max-width: @wrapper-maxwidth;
115 115 margin: 0 auto;
116 116 }
117 117
118 118 #content {
119 119 clear: both;
120 120 padding: 0 @contentpadding;
121 121 }
122 122
123 123 .advanced-settings-fields{
124 124 input{
125 125 margin-left: @textmargin;
126 126 margin-right: @padding/2;
127 127 }
128 128 }
129 129
130 130 .cs_files_title {
131 131 margin: @pagepadding 0 0;
132 132 }
133 133
134 134 input.inline[type="file"] {
135 135 display: inline;
136 136 }
137 137
138 138 .error_page {
139 139 margin: 10% auto;
140 140
141 141 h1 {
142 142 color: @grey2;
143 143 }
144 144
145 145 .alert {
146 146 margin: @padding 0;
147 147 }
148 148
149 149 .error-branding {
150 150 font-family: @text-semibold;
151 151 color: @grey4;
152 152 }
153 153
154 154 .error_message {
155 155 font-family: @text-regular;
156 156 }
157 157
158 158 .sidebar {
159 159 min-height: 275px;
160 160 margin: 0;
161 161 padding: 0 0 @sidebarpadding @sidebarpadding;
162 162 border: none;
163 163 }
164 164
165 165 .main-content {
166 166 position: relative;
167 167 margin: 0 @sidebarpadding @sidebarpadding;
168 168 padding: 0 0 0 @sidebarpadding;
169 169 border-left: @border-thickness solid @grey5;
170 170
171 171 @media (max-width:767px) {
172 172 clear: both;
173 173 width: 100%;
174 174 margin: 0;
175 175 border: none;
176 176 }
177 177 }
178 178
179 179 .inner-column {
180 180 float: left;
181 181 width: 29.75%;
182 182 min-height: 150px;
183 183 margin: @sidebarpadding 2% 0 0;
184 184 padding: 0 2% 0 0;
185 185 border-right: @border-thickness solid @grey5;
186 186
187 187 @media (max-width:767px) {
188 188 clear: both;
189 189 width: 100%;
190 190 border: none;
191 191 }
192 192
193 193 ul {
194 194 padding-left: 1.25em;
195 195 }
196 196
197 197 &:last-child {
198 198 margin: @sidebarpadding 0 0;
199 199 border: none;
200 200 }
201 201
202 202 h4 {
203 203 margin: 0 0 @padding;
204 204 font-family: @text-semibold;
205 205 }
206 206 }
207 207 }
208 208 .error-page-logo {
209 209 width: 130px;
210 210 height: 160px;
211 211 }
212 212
213 213 // HEADER
214 214 .header {
215 215
216 216 // TODO: johbo: Fix login pages, so that they work without a min-height
217 217 // for the header and then remove the min-height. I chose a smaller value
218 218 // intentionally here to avoid rendering issues in the main navigation.
219 219 min-height: 49px;
220 220
221 221 position: relative;
222 222 vertical-align: bottom;
223 223 padding: 0 @header-padding;
224 224 background-color: @grey2;
225 225 color: @grey5;
226 226
227 227 .title {
228 228 overflow: visible;
229 229 }
230 230
231 231 &:before,
232 232 &:after {
233 233 content: "";
234 234 clear: both;
235 235 width: 100%;
236 236 }
237 237
238 238 // TODO: johbo: Avoids breaking "Repositories" chooser
239 239 .select2-container .select2-choice .select2-arrow {
240 240 display: none;
241 241 }
242 242 }
243 243
244 244 #header-inner {
245 245 &.title {
246 246 margin: 0;
247 247 }
248 248 &:before,
249 249 &:after {
250 250 content: "";
251 251 clear: both;
252 252 }
253 253 }
254 254
255 255 // Gists
256 256 #files_data {
257 257 clear: both; //for firefox
258 258 }
259 259 #gistid {
260 260 margin-right: @padding;
261 261 }
262 262
263 263 // Global Settings Editor
264 264 .textarea.editor {
265 265 float: left;
266 266 position: relative;
267 267 max-width: @texteditor-width;
268 268
269 269 select {
270 270 position: absolute;
271 271 top:10px;
272 272 right:0;
273 273 }
274 274
275 275 .CodeMirror {
276 276 margin: 0;
277 277 }
278 278
279 279 .help-block {
280 280 margin: 0 0 @padding;
281 281 padding:.5em;
282 282 background-color: @grey6;
283 283 }
284 284 }
285 285
286 286 ul.auth_plugins {
287 287 margin: @padding 0 @padding @legend-width;
288 288 padding: 0;
289 289
290 290 li {
291 291 margin-bottom: @padding;
292 292 line-height: 1em;
293 293 list-style-type: none;
294 294
295 295 .auth_buttons .btn {
296 296 margin-right: @padding;
297 297 }
298 298
299 299 &:before { content: none; }
300 300 }
301 301 }
302 302
303 303
304 304 // My Account PR list
305 305
306 306 #show_closed {
307 307 margin: 0 1em 0 0;
308 308 }
309 309
310 310 .pullrequestlist {
311 311 .closed {
312 312 background-color: @grey6;
313 313 }
314 314 .td-status {
315 315 padding-left: .5em;
316 316 }
317 317 .log-container .truncate {
318 318 height: 2.75em;
319 319 white-space: pre-line;
320 320 }
321 321 table.rctable .user {
322 322 padding-left: 0;
323 323 }
324 324 table.rctable {
325 325 td.td-description,
326 326 .rc-user {
327 327 min-width: auto;
328 328 }
329 329 }
330 330 }
331 331
332 332 // Pull Requests
333 333
334 334 .pullrequests_section_head {
335 335 display: block;
336 336 clear: both;
337 337 margin: @padding 0;
338 338 font-family: @text-bold;
339 339 }
340 340
341 341 .pr-origininfo, .pr-targetinfo {
342 342 position: relative;
343 343
344 344 .tag {
345 345 display: inline-block;
346 346 margin: 0 1em .5em 0;
347 347 }
348 348
349 349 .clone-url {
350 350 display: inline-block;
351 351 margin: 0 0 .5em 0;
352 352 padding: 0;
353 353 line-height: 1.2em;
354 354 }
355 355 }
356 356
357 357 .pr-pullinfo {
358 358 clear: both;
359 359 margin: .5em 0;
360 360 }
361 361
362 362 #pr-title-input {
363 363 width: 72%;
364 364 font-size: 1em;
365 365 font-family: @text-bold;
366 366 margin: 0;
367 367 padding: 0 0 0 @padding/4;
368 368 line-height: 1.7em;
369 369 color: @text-color;
370 370 letter-spacing: .02em;
371 371 }
372 372
373 373 #pullrequest_title {
374 374 width: 100%;
375 375 box-sizing: border-box;
376 376 }
377 377
378 378 #pr_open_message {
379 379 border: @border-thickness solid #fff;
380 380 border-radius: @border-radius;
381 381 padding: @padding-large-vertical @padding-large-vertical @padding-large-vertical 0;
382 382 text-align: right;
383 383 overflow: hidden;
384 384 }
385 385
386 386 .pr-submit-button {
387 387 float: right;
388 388 margin: 0 0 0 5px;
389 389 }
390 390
391 391 .pr-spacing-container {
392 392 padding: 20px;
393 393 clear: both
394 394 }
395 395
396 396 #pr-description-input {
397 397 margin-bottom: 0;
398 398 }
399 399
400 400 .pr-description-label {
401 401 vertical-align: top;
402 402 }
403 403
404 404 .perms_section_head {
405 405 min-width: 625px;
406 406
407 407 h2 {
408 408 margin-bottom: 0;
409 409 }
410 410
411 411 .label-checkbox {
412 412 float: left;
413 413 }
414 414
415 415 &.field {
416 416 margin: @space 0 @padding;
417 417 }
418 418
419 419 &:first-child.field {
420 420 margin-top: 0;
421 421
422 422 .label {
423 423 margin-top: 0;
424 424 padding-top: 0;
425 425 }
426 426
427 427 .radios {
428 428 padding-top: 0;
429 429 }
430 430 }
431 431
432 432 .radios {
433 433 float: right;
434 434 position: relative;
435 435 width: 405px;
436 436 }
437 437 }
438 438
439 439 //--- MODULES ------------------//
440 440
441 441
442 442 // Server Announcement
443 443 #server-announcement {
444 444 width: 95%;
445 445 margin: @padding auto;
446 446 padding: @padding;
447 447 border-width: 2px;
448 448 border-style: solid;
449 449 .border-radius(2px);
450 450 font-family: @text-bold;
451 451
452 452 &.info { border-color: @alert4; background-color: @alert4-inner; }
453 453 &.warning { border-color: @alert3; background-color: @alert3-inner; }
454 454 &.error { border-color: @alert2; background-color: @alert2-inner; }
455 455 &.success { border-color: @alert1; background-color: @alert1-inner; }
456 456 &.neutral { border-color: @grey3; background-color: @grey6; }
457 457 }
458 458
459 459 // Fixed Sidebar Column
460 460 .sidebar-col-wrapper {
461 461 padding-left: @sidebar-all-width;
462 462
463 463 .sidebar {
464 464 width: @sidebar-width;
465 465 margin-left: -@sidebar-all-width;
466 466 }
467 467 }
468 468
469 469 .sidebar-col-wrapper.scw-small {
470 470 padding-left: @sidebar-small-all-width;
471 471
472 472 .sidebar {
473 473 width: @sidebar-small-width;
474 474 margin-left: -@sidebar-small-all-width;
475 475 }
476 476 }
477 477
478 478
479 479 // FOOTER
480 480 #footer {
481 481 padding: 0;
482 482 text-align: center;
483 483 vertical-align: middle;
484 484 color: @grey2;
485 485 background-color: @grey6;
486 486
487 487 p {
488 488 margin: 0;
489 489 padding: 1em;
490 490 line-height: 1em;
491 491 }
492 492
493 493 .server-instance { //server instance
494 494 display: none;
495 495 }
496 496
497 497 .title {
498 498 float: none;
499 499 margin: 0 auto;
500 500 }
501 501 }
502 502
503 503 button.close {
504 504 padding: 0;
505 505 cursor: pointer;
506 506 background: transparent;
507 507 border: 0;
508 508 .box-shadow(none);
509 509 -webkit-appearance: none;
510 510 }
511 511
512 512 .close {
513 513 float: right;
514 514 font-size: 21px;
515 515 font-family: @text-bootstrap;
516 516 line-height: 1em;
517 517 font-weight: bold;
518 518 color: @grey2;
519 519
520 520 &:hover,
521 521 &:focus {
522 522 color: @grey1;
523 523 text-decoration: none;
524 524 cursor: pointer;
525 525 }
526 526 }
527 527
528 528 // GRID
529 529 .sorting,
530 530 .sorting_desc,
531 531 .sorting_asc {
532 532 cursor: pointer;
533 533 }
534 534 .sorting_desc:after {
535 535 content: "\00A0\25B2";
536 536 font-size: .75em;
537 537 }
538 538 .sorting_asc:after {
539 539 content: "\00A0\25BC";
540 540 font-size: .68em;
541 541 }
542 542
543 543
544 544 .user_auth_tokens {
545 545
546 546 &.truncate {
547 547 white-space: nowrap;
548 548 overflow: hidden;
549 549 text-overflow: ellipsis;
550 550 }
551 551
552 552 .fields .field .input {
553 553 margin: 0;
554 554 }
555 555
556 556 input#description {
557 557 width: 100px;
558 558 margin: 0;
559 559 }
560 560
561 561 .drop-menu {
562 562 // TODO: johbo: Remove this, should work out of the box when
563 563 // having multiple inputs inline
564 564 margin: 0 0 0 5px;
565 565 }
566 566 }
567 567 #user_list_table {
568 568 .closed {
569 569 background-color: @grey6;
570 570 }
571 571 }
572 572
573 573
574 574 input {
575 575 &.disabled {
576 576 opacity: .5;
577 577 }
578 578 }
579 579
580 580 // remove extra padding in firefox
581 581 input::-moz-focus-inner { border:0; padding:0 }
582 582
583 583 .adjacent input {
584 584 margin-bottom: @padding;
585 585 }
586 586
587 587 .permissions_boxes {
588 588 display: block;
589 589 }
590 590
591 591 //TODO: lisa: this should be in tables
592 592 .show_more_col {
593 593 width: 20px;
594 594 }
595 595
596 596 //FORMS
597 597
598 598 .medium-inline,
599 599 input#description.medium-inline {
600 600 display: inline;
601 601 width: @medium-inline-input-width;
602 602 min-width: 100px;
603 603 }
604 604
605 605 select {
606 606 //reset
607 607 -webkit-appearance: none;
608 608 -moz-appearance: none;
609 609
610 610 display: inline-block;
611 611 height: 28px;
612 612 width: auto;
613 613 margin: 0 @padding @padding 0;
614 614 padding: 0 18px 0 8px;
615 615 line-height:1em;
616 616 font-size: @basefontsize;
617 617 border: @border-thickness solid @rcblue;
618 618 background:white url("../images/dt-arrow-dn.png") no-repeat 100% 50%;
619 619 color: @rcblue;
620 620
621 621 &:after {
622 622 content: "\00A0\25BE";
623 623 }
624 624
625 625 &:focus {
626 626 outline: none;
627 627 }
628 628 }
629 629
630 630 option {
631 631 &:focus {
632 632 outline: none;
633 633 }
634 634 }
635 635
636 636 input,
637 637 textarea {
638 638 padding: @input-padding;
639 639 border: @input-border-thickness solid @border-highlight-color;
640 640 .border-radius (@border-radius);
641 641 font-family: @text-light;
642 642 font-size: @basefontsize;
643 643
644 644 &.input-sm {
645 645 padding: 5px;
646 646 }
647 647
648 648 &#description {
649 649 min-width: @input-description-minwidth;
650 650 min-height: 1em;
651 651 padding: 10px;
652 652 }
653 653 }
654 654
655 655 .field-sm {
656 656 input,
657 657 textarea {
658 658 padding: 5px;
659 659 }
660 660 }
661 661
662 662 textarea {
663 663 display: block;
664 664 clear: both;
665 665 width: 100%;
666 666 min-height: 100px;
667 667 margin-bottom: @padding;
668 668 .box-sizing(border-box);
669 669 overflow: auto;
670 670 }
671 671
672 672 label {
673 673 font-family: @text-light;
674 674 }
675 675
676 676 // GRAVATARS
677 677 // centers gravatar on username to the right
678 678
679 679 .gravatar {
680 680 display: inline;
681 681 min-width: 16px;
682 682 min-height: 16px;
683 683 margin: -5px 0;
684 684 padding: 0;
685 685 line-height: 1em;
686 686 border: 1px solid @grey4;
687 687
688 688 &.gravatar-large {
689 689 margin: -0.5em .25em -0.5em 0;
690 690 }
691 691
692 692 & + .user {
693 693 display: inline;
694 694 margin: 0;
695 695 padding: 0 0 0 .17em;
696 696 line-height: 1em;
697 697 }
698 698 }
699 699
700 700 .user-inline-data {
701 701 display: inline-block;
702 702 float: left;
703 703 padding-left: .5em;
704 704 line-height: 1.3em;
705 705 }
706 706
707 707 .rc-user { // gravatar + user wrapper
708 708 float: left;
709 709 position: relative;
710 710 min-width: 100px;
711 711 max-width: 200px;
712 712 min-height: (@gravatar-size + @border-thickness * 2); // account for border
713 713 display: block;
714 714 padding: 0 0 0 (@gravatar-size + @basefontsize/2 + @border-thickness * 2);
715 715
716 716
717 717 .gravatar {
718 718 display: block;
719 719 position: absolute;
720 720 top: 0;
721 721 left: 0;
722 722 min-width: @gravatar-size;
723 723 min-height: @gravatar-size;
724 724 margin: 0;
725 725 }
726 726
727 727 .user {
728 728 display: block;
729 729 max-width: 175px;
730 730 padding-top: 2px;
731 731 overflow: hidden;
732 732 text-overflow: ellipsis;
733 733 }
734 734 }
735 735
736 736 .gist-gravatar,
737 737 .journal_container {
738 738 .gravatar-large {
739 739 margin: 0 .5em -10px 0;
740 740 }
741 741 }
742 742
743 743
744 744 // ADMIN SETTINGS
745 745
746 746 // Tag Patterns
747 747 .tag_patterns {
748 748 .tag_input {
749 749 margin-bottom: @padding;
750 750 }
751 751 }
752 752
753 753 .locked_input {
754 754 position: relative;
755 755
756 756 input {
757 757 display: inline;
758 758 margin-top: 3px;
759 759 }
760 760
761 761 br {
762 762 display: none;
763 763 }
764 764
765 765 .error-message {
766 766 float: left;
767 767 width: 100%;
768 768 }
769 769
770 770 .lock_input_button {
771 771 display: inline;
772 772 }
773 773
774 774 .help-block {
775 775 clear: both;
776 776 }
777 777 }
778 778
779 779 // Notifications
780 780
781 781 .notifications_buttons {
782 782 margin: 0 0 @space 0;
783 783 padding: 0;
784 784
785 785 .btn {
786 786 display: inline-block;
787 787 }
788 788 }
789 789
790 790 .notification-list {
791 791
792 792 div {
793 793 display: inline-block;
794 794 vertical-align: middle;
795 795 }
796 796
797 797 .container {
798 798 display: block;
799 799 margin: 0 0 @padding 0;
800 800 }
801 801
802 802 .delete-notifications {
803 803 margin-left: @padding;
804 804 text-align: right;
805 805 cursor: pointer;
806 806 }
807 807
808 808 .read-notifications {
809 809 margin-left: @padding/2;
810 810 text-align: right;
811 811 width: 35px;
812 812 cursor: pointer;
813 813 }
814 814
815 815 .icon-minus-sign {
816 816 color: @alert2;
817 817 }
818 818
819 819 .icon-ok-sign {
820 820 color: @alert1;
821 821 }
822 822 }
823 823
824 824 .user_settings {
825 825 float: left;
826 826 clear: both;
827 827 display: block;
828 828 width: 100%;
829 829
830 830 .gravatar_box {
831 831 margin-bottom: @padding;
832 832
833 833 &:after {
834 834 content: " ";
835 835 clear: both;
836 836 width: 100%;
837 837 }
838 838 }
839 839
840 840 .fields .field {
841 841 clear: both;
842 842 }
843 843 }
844 844
845 845 .advanced_settings {
846 846 margin-bottom: @space;
847 847
848 848 .help-block {
849 849 margin-left: 0;
850 850 }
851 851
852 852 button + .help-block {
853 853 margin-top: @padding;
854 854 }
855 855 }
856 856
857 857 // admin settings radio buttons and labels
858 858 .label-2 {
859 859 float: left;
860 860 width: @label2-width;
861 861
862 862 label {
863 863 color: @grey1;
864 864 }
865 865 }
866 866 .checkboxes {
867 867 float: left;
868 868 width: @checkboxes-width;
869 869 margin-bottom: @padding;
870 870
871 871 .checkbox {
872 872 width: 100%;
873 873
874 874 label {
875 875 margin: 0;
876 876 padding: 0;
877 877 }
878 878 }
879 879
880 880 .checkbox + .checkbox {
881 881 display: inline-block;
882 882 }
883 883
884 884 label {
885 885 margin-right: 1em;
886 886 }
887 887 }
888 888
889 889 // CHANGELOG
890 890 .container_header {
891 891 float: left;
892 892 display: block;
893 893 width: 100%;
894 894 margin: @padding 0 @padding;
895 895
896 896 #filter_changelog {
897 897 float: left;
898 898 margin-right: @padding;
899 899 }
900 900
901 901 .breadcrumbs_light {
902 902 display: inline-block;
903 903 }
904 904 }
905 905
906 906 .info_box {
907 907 float: right;
908 908 }
909 909
910 910
911 911 #graph_nodes {
912 912 padding-top: 43px;
913 913 }
914 914
915 915 #graph_content{
916 916
917 917 // adjust for table headers so that graph renders properly
918 918 // #graph_nodes padding - table cell padding
919 919 padding-top: (@space - (@basefontsize * 2.4));
920 920
921 921 &.graph_full_width {
922 922 width: 100%;
923 923 max-width: 100%;
924 924 }
925 925 }
926 926
927 927 #graph {
928 928 .flag_status {
929 929 margin: 0;
930 930 }
931 931
932 932 .pagination-left {
933 933 float: left;
934 934 clear: both;
935 935 }
936 936
937 937 .log-container {
938 938 max-width: 345px;
939 939
940 940 .message{
941 941 max-width: 340px;
942 942 }
943 943 }
944 944
945 945 .graph-col-wrapper {
946 946 padding-left: 110px;
947 947
948 948 #graph_nodes {
949 949 width: 100px;
950 950 margin-left: -110px;
951 951 float: left;
952 952 clear: left;
953 953 }
954 954 }
955 955 }
956 956
957 957 #filter_changelog {
958 958 float: left;
959 959 }
960 960
961 961
962 962 //--- THEME ------------------//
963 963
964 964 #logo {
965 965 float: left;
966 966 margin: 9px 0 0 0;
967 967
968 968 .header {
969 969 background-color: transparent;
970 970 }
971 971
972 972 a {
973 973 display: inline-block;
974 974 }
975 975
976 976 img {
977 977 height:30px;
978 978 }
979 979 }
980 980
981 981 .logo-wrapper {
982 982 float:left;
983 983 }
984 984
985 985 .branding{
986 986 float: left;
987 987 padding: 9px 2px;
988 988 line-height: 1em;
989 989 font-size: @navigation-fontsize;
990 990 }
991 991
992 992 img {
993 993 border: none;
994 994 outline: none;
995 995 }
996 996 user-profile-header
997 997 label {
998 998
999 999 input[type="checkbox"] {
1000 1000 margin-right: 1em;
1001 1001 }
1002 1002 input[type="radio"] {
1003 1003 margin-right: 1em;
1004 1004 }
1005 1005 }
1006 1006
1007 1007 .flag_status {
1008 1008 margin: 2px 8px 6px 2px;
1009 1009 &.under_review {
1010 1010 .circle(5px, @alert3);
1011 1011 }
1012 1012 &.approved {
1013 1013 .circle(5px, @alert1);
1014 1014 }
1015 1015 &.rejected,
1016 1016 &.forced_closed{
1017 1017 .circle(5px, @alert2);
1018 1018 }
1019 1019 &.not_reviewed {
1020 1020 .circle(5px, @grey5);
1021 1021 }
1022 1022 }
1023 1023
1024 1024 .flag_status_comment_box {
1025 1025 margin: 5px 6px 0px 2px;
1026 1026 }
1027 1027 .test_pattern_preview {
1028 1028 margin: @space 0;
1029 1029
1030 1030 p {
1031 1031 margin-bottom: 0;
1032 1032 border-bottom: @border-thickness solid @border-default-color;
1033 1033 color: @grey3;
1034 1034 }
1035 1035
1036 1036 .btn {
1037 1037 margin-bottom: @padding;
1038 1038 }
1039 1039 }
1040 1040 #test_pattern_result {
1041 1041 display: none;
1042 1042 &:extend(pre);
1043 1043 padding: .9em;
1044 1044 color: @grey3;
1045 1045 background-color: @grey7;
1046 1046 border-right: @border-thickness solid @border-default-color;
1047 1047 border-bottom: @border-thickness solid @border-default-color;
1048 1048 border-left: @border-thickness solid @border-default-color;
1049 1049 }
1050 1050
1051 1051 #repo_vcs_settings {
1052 1052 #inherit_overlay_vcs_default {
1053 1053 display: none;
1054 1054 }
1055 1055 #inherit_overlay_vcs_custom {
1056 1056 display: custom;
1057 1057 }
1058 1058 &.inherited {
1059 1059 #inherit_overlay_vcs_default {
1060 1060 display: block;
1061 1061 }
1062 1062 #inherit_overlay_vcs_custom {
1063 1063 display: none;
1064 1064 }
1065 1065 }
1066 1066 }
1067 1067
1068 1068 .issue-tracker-link {
1069 1069 color: @rcblue;
1070 1070 }
1071 1071
1072 1072 // Issue Tracker Table Show/Hide
1073 1073 #repo_issue_tracker {
1074 1074 #inherit_overlay {
1075 1075 display: none;
1076 1076 }
1077 1077 #custom_overlay {
1078 1078 display: custom;
1079 1079 }
1080 1080 &.inherited {
1081 1081 #inherit_overlay {
1082 1082 display: block;
1083 1083 }
1084 1084 #custom_overlay {
1085 1085 display: none;
1086 1086 }
1087 1087 }
1088 1088 }
1089 1089 table.issuetracker {
1090 1090 &.readonly {
1091 1091 tr, td {
1092 1092 color: @grey3;
1093 1093 }
1094 1094 }
1095 1095 .edit {
1096 1096 display: none;
1097 1097 }
1098 1098 .editopen {
1099 1099 .edit {
1100 1100 display: inline;
1101 1101 }
1102 1102 .entry {
1103 1103 display: none;
1104 1104 }
1105 1105 }
1106 1106 tr td.td-action {
1107 1107 min-width: 117px;
1108 1108 }
1109 1109 td input {
1110 1110 max-width: none;
1111 1111 min-width: 30px;
1112 1112 width: 80%;
1113 1113 }
1114 1114 .issuetracker_pref input {
1115 1115 width: 40%;
1116 1116 }
1117 1117 input.edit_issuetracker_update {
1118 1118 margin-right: 0;
1119 1119 width: auto;
1120 1120 }
1121 1121 }
1122 1122
1123 1123 table.integrations {
1124 1124 .td-icon {
1125 1125 width: 20px;
1126 1126 .integration-icon {
1127 1127 height: 20px;
1128 1128 width: 20px;
1129 1129 }
1130 1130 }
1131 1131 }
1132 1132
1133 1133 .integrations {
1134 1134 a.integration-box {
1135 1135 color: @text-color;
1136 1136 &:hover {
1137 1137 .panel {
1138 1138 background: #fbfbfb;
1139 1139 }
1140 1140 }
1141 1141 .integration-icon {
1142 1142 width: 30px;
1143 1143 height: 30px;
1144 1144 margin-right: 20px;
1145 1145 float: left;
1146 1146 }
1147 1147
1148 1148 .panel-body {
1149 1149 padding: 10px;
1150 1150 }
1151 1151 .panel {
1152 1152 margin-bottom: 10px;
1153 1153 }
1154 1154 h2 {
1155 1155 display: inline-block;
1156 1156 margin: 0;
1157 1157 min-width: 140px;
1158 1158 }
1159 1159 }
1160 1160 }
1161 1161
1162 1162 //Permissions Settings
1163 1163 #add_perm {
1164 1164 margin: 0 0 @padding;
1165 1165 cursor: pointer;
1166 1166 }
1167 1167
1168 1168 .perm_ac {
1169 1169 input {
1170 1170 width: 95%;
1171 1171 }
1172 1172 }
1173 1173
1174 1174 .autocomplete-suggestions {
1175 1175 width: auto !important; // overrides autocomplete.js
1176 1176 margin: 0;
1177 1177 border: @border-thickness solid @rcblue;
1178 1178 border-radius: @border-radius;
1179 1179 color: @rcblue;
1180 1180 background-color: white;
1181 1181 }
1182 1182 .autocomplete-selected {
1183 1183 background: #F0F0F0;
1184 1184 }
1185 1185 .ac-container-wrap {
1186 1186 margin: 0;
1187 1187 padding: 8px;
1188 1188 border-bottom: @border-thickness solid @rclightblue;
1189 1189 list-style-type: none;
1190 1190 cursor: pointer;
1191 1191
1192 1192 &:hover {
1193 1193 background-color: @rclightblue;
1194 1194 }
1195 1195
1196 1196 img {
1197 1197 height: @gravatar-size;
1198 1198 width: @gravatar-size;
1199 1199 margin-right: 1em;
1200 1200 }
1201 1201
1202 1202 strong {
1203 1203 font-weight: normal;
1204 1204 }
1205 1205 }
1206 1206
1207 1207 // Settings Dropdown
1208 1208 .user-menu .container {
1209 1209 padding: 0 4px;
1210 1210 margin: 0;
1211 1211 }
1212 1212
1213 1213 .user-menu .gravatar {
1214 1214 cursor: pointer;
1215 1215 }
1216 1216
1217 1217 .codeblock {
1218 1218 margin-bottom: @padding;
1219 1219 clear: both;
1220 1220
1221 1221 .stats{
1222 1222 overflow: hidden;
1223 1223 }
1224 1224
1225 1225 .message{
1226 1226 textarea{
1227 1227 margin: 0;
1228 1228 }
1229 1229 }
1230 1230
1231 1231 .code-header {
1232 1232 .stats {
1233 1233 line-height: 2em;
1234 1234
1235 1235 .revision_id {
1236 1236 margin-left: 0;
1237 1237 }
1238 1238 .buttons {
1239 1239 padding-right: 0;
1240 1240 }
1241 1241 }
1242 1242
1243 1243 .item{
1244 1244 margin-right: 0.5em;
1245 1245 }
1246 1246 }
1247 1247
1248 1248 #editor_container{
1249 1249 position: relative;
1250 1250 margin: @padding;
1251 1251 }
1252 1252 }
1253 1253
1254 1254 #file_history_container {
1255 1255 display: none;
1256 1256 }
1257 1257
1258 1258 .file-history-inner {
1259 1259 margin-bottom: 10px;
1260 1260 }
1261 1261
1262 1262 // Pull Requests
1263 1263 .summary-details {
1264 1264 width: 72%;
1265 1265 }
1266 1266 .pr-summary {
1267 1267 border-bottom: @border-thickness solid @grey5;
1268 1268 margin-bottom: @space;
1269 1269 }
1270 1270 .reviewers-title {
1271 1271 width: 25%;
1272 1272 min-width: 200px;
1273 1273 }
1274 1274 .reviewers {
1275 1275 width: 25%;
1276 1276 min-width: 200px;
1277 1277 }
1278 1278 .reviewers ul li {
1279 1279 position: relative;
1280 1280 width: 100%;
1281 1281 margin-bottom: 8px;
1282 1282 }
1283 1283 .reviewers_member {
1284 1284 width: 100%;
1285 1285 overflow: auto;
1286 1286 }
1287 1287 .reviewer_reason {
1288 1288 padding-left: 20px;
1289 1289 }
1290 1290 .reviewer_status {
1291 1291 display: inline-block;
1292 1292 vertical-align: top;
1293 1293 width: 7%;
1294 1294 min-width: 20px;
1295 1295 height: 1.2em;
1296 1296 margin-top: 3px;
1297 1297 line-height: 1em;
1298 1298 }
1299 1299
1300 1300 .reviewer_name {
1301 1301 display: inline-block;
1302 1302 max-width: 83%;
1303 1303 padding-right: 20px;
1304 1304 vertical-align: middle;
1305 1305 line-height: 1;
1306 1306
1307 1307 .rc-user {
1308 1308 min-width: 0;
1309 1309 margin: -2px 1em 0 0;
1310 1310 }
1311 1311
1312 1312 .reviewer {
1313 1313 float: left;
1314 1314 }
1315 1315
1316 1316 &.to-delete {
1317 1317 .user,
1318 1318 .reviewer {
1319 1319 text-decoration: line-through;
1320 1320 }
1321 1321 }
1322 1322 }
1323 1323
1324 1324 .reviewer_member_remove {
1325 1325 position: absolute;
1326 1326 right: 0;
1327 1327 top: 0;
1328 1328 width: 16px;
1329 1329 margin-bottom: 10px;
1330 1330 padding: 0;
1331 1331 color: black;
1332 1332 }
1333 1333 .reviewer_member_status {
1334 1334 margin-top: 5px;
1335 1335 }
1336 1336 .pr-summary #summary{
1337 1337 width: 100%;
1338 1338 }
1339 1339 .pr-summary .action_button:hover {
1340 1340 border: 0;
1341 1341 cursor: pointer;
1342 1342 }
1343 1343 .pr-details-title {
1344 1344 padding-bottom: 8px;
1345 1345 border-bottom: @border-thickness solid @grey5;
1346
1347 .action_button.disabled {
1348 color: @grey4;
1349 cursor: inherit;
1350 }
1346 1351 .action_button {
1347 1352 color: @rcblue;
1348 1353 }
1349 1354 }
1350 1355 .pr-details-content {
1351 1356 margin-top: @textmargin;
1352 1357 margin-bottom: @textmargin;
1353 1358 }
1354 1359 .pr-description {
1355 1360 white-space:pre-wrap;
1356 1361 }
1357 1362 .group_members {
1358 1363 margin-top: 0;
1359 1364 padding: 0;
1360 1365 list-style: outside none none;
1361 1366
1362 1367 img {
1363 1368 height: @gravatar-size;
1364 1369 width: @gravatar-size;
1365 1370 margin-right: .5em;
1366 1371 margin-left: 3px;
1367 1372 }
1368 1373 }
1369 1374 .reviewer_ac .ac-input {
1370 1375 width: 92%;
1371 1376 margin-bottom: 1em;
1372 1377 }
1373 1378 #update_commits {
1374 1379 float: right;
1375 1380 }
1376 1381 .compare_view_commits tr{
1377 1382 height: 20px;
1378 1383 }
1379 1384 .compare_view_commits td {
1380 1385 vertical-align: top;
1381 1386 padding-top: 10px;
1382 1387 }
1383 1388 .compare_view_commits .author {
1384 1389 margin-left: 5px;
1385 1390 }
1386 1391
1387 1392 .compare_view_files {
1388 1393 width: 100%;
1389 1394
1390 1395 td {
1391 1396 vertical-align: middle;
1392 1397 }
1393 1398 }
1394 1399
1395 1400 .compare_view_filepath {
1396 1401 color: @grey1;
1397 1402 }
1398 1403
1399 1404 .show_more {
1400 1405 display: inline-block;
1401 1406 position: relative;
1402 1407 vertical-align: middle;
1403 1408 width: 4px;
1404 1409 height: @basefontsize;
1405 1410
1406 1411 &:after {
1407 1412 content: "\00A0\25BE";
1408 1413 display: inline-block;
1409 1414 width:10px;
1410 1415 line-height: 5px;
1411 1416 font-size: 12px;
1412 1417 cursor: pointer;
1413 1418 }
1414 1419 }
1415 1420
1416 1421 .journal_more .show_more {
1417 1422 display: inline;
1418 1423
1419 1424 &:after {
1420 1425 content: none;
1421 1426 }
1422 1427 }
1423 1428
1424 1429 .open .show_more:after,
1425 1430 .select2-dropdown-open .show_more:after {
1426 1431 .rotate(180deg);
1427 1432 margin-left: 4px;
1428 1433 }
1429 1434
1430 1435
1431 1436 .compare_view_commits .collapse_commit:after {
1432 1437 cursor: pointer;
1433 1438 content: "\00A0\25B4";
1434 1439 margin-left: -3px;
1435 1440 font-size: 17px;
1436 1441 color: @grey4;
1437 1442 }
1438 1443
1439 1444 .diff_links {
1440 1445 margin-left: 8px;
1441 1446 }
1442 1447
1443 1448 p.ancestor {
1444 1449 margin: @padding 0;
1445 1450 }
1446 1451
1447 1452 .cs_icon_td input[type="checkbox"] {
1448 1453 display: none;
1449 1454 }
1450 1455
1451 1456 .cs_icon_td .expand_file_icon:after {
1452 1457 cursor: pointer;
1453 1458 content: "\00A0\25B6";
1454 1459 font-size: 12px;
1455 1460 color: @grey4;
1456 1461 }
1457 1462
1458 1463 .cs_icon_td .collapse_file_icon:after {
1459 1464 cursor: pointer;
1460 1465 content: "\00A0\25BC";
1461 1466 font-size: 12px;
1462 1467 color: @grey4;
1463 1468 }
1464 1469
1465 1470 /*new binary
1466 1471 NEW_FILENODE = 1
1467 1472 DEL_FILENODE = 2
1468 1473 MOD_FILENODE = 3
1469 1474 RENAMED_FILENODE = 4
1470 1475 COPIED_FILENODE = 5
1471 1476 CHMOD_FILENODE = 6
1472 1477 BIN_FILENODE = 7
1473 1478 */
1474 1479 .cs_files_expand {
1475 1480 font-size: @basefontsize + 5px;
1476 1481 line-height: 1.8em;
1477 1482 float: right;
1478 1483 }
1479 1484
1480 1485 .cs_files_expand span{
1481 1486 color: @rcblue;
1482 1487 cursor: pointer;
1483 1488 }
1484 1489 .cs_files {
1485 1490 clear: both;
1486 1491 padding-bottom: @padding;
1487 1492
1488 1493 .cur_cs {
1489 1494 margin: 10px 2px;
1490 1495 font-weight: bold;
1491 1496 }
1492 1497
1493 1498 .node {
1494 1499 float: left;
1495 1500 }
1496 1501
1497 1502 .changes {
1498 1503 float: right;
1499 1504 color: white;
1500 1505 font-size: @basefontsize - 4px;
1501 1506 margin-top: 4px;
1502 1507 opacity: 0.6;
1503 1508 filter: Alpha(opacity=60); /* IE8 and earlier */
1504 1509
1505 1510 .added {
1506 1511 background-color: @alert1;
1507 1512 float: left;
1508 1513 text-align: center;
1509 1514 }
1510 1515
1511 1516 .deleted {
1512 1517 background-color: @alert2;
1513 1518 float: left;
1514 1519 text-align: center;
1515 1520 }
1516 1521
1517 1522 .bin {
1518 1523 background-color: @alert1;
1519 1524 text-align: center;
1520 1525 }
1521 1526
1522 1527 /*new binary*/
1523 1528 .bin.bin1 {
1524 1529 background-color: @alert1;
1525 1530 text-align: center;
1526 1531 }
1527 1532
1528 1533 /*deleted binary*/
1529 1534 .bin.bin2 {
1530 1535 background-color: @alert2;
1531 1536 text-align: center;
1532 1537 }
1533 1538
1534 1539 /*mod binary*/
1535 1540 .bin.bin3 {
1536 1541 background-color: @grey2;
1537 1542 text-align: center;
1538 1543 }
1539 1544
1540 1545 /*rename file*/
1541 1546 .bin.bin4 {
1542 1547 background-color: @alert4;
1543 1548 text-align: center;
1544 1549 }
1545 1550
1546 1551 /*copied file*/
1547 1552 .bin.bin5 {
1548 1553 background-color: @alert4;
1549 1554 text-align: center;
1550 1555 }
1551 1556
1552 1557 /*chmod file*/
1553 1558 .bin.bin6 {
1554 1559 background-color: @grey2;
1555 1560 text-align: center;
1556 1561 }
1557 1562 }
1558 1563 }
1559 1564
1560 1565 .cs_files .cs_added, .cs_files .cs_A,
1561 1566 .cs_files .cs_added, .cs_files .cs_M,
1562 1567 .cs_files .cs_added, .cs_files .cs_D {
1563 1568 height: 16px;
1564 1569 padding-right: 10px;
1565 1570 margin-top: 7px;
1566 1571 text-align: left;
1567 1572 }
1568 1573
1569 1574 .cs_icon_td {
1570 1575 min-width: 16px;
1571 1576 width: 16px;
1572 1577 }
1573 1578
1574 1579 .pull-request-merge {
1575 1580 padding: 10px 0;
1576 1581 margin-top: 10px;
1577 1582 margin-bottom: 20px;
1578 1583 }
1579 1584
1580 1585 .pull-request-merge .pull-request-wrap {
1581 1586 height: 25px;
1582 1587 padding: 5px 0;
1583 1588 }
1584 1589
1585 1590 .pull-request-merge span {
1586 1591 margin-right: 10px;
1587 1592 }
1588 1593 #close_pull_request {
1589 1594 margin-right: 0px;
1590 1595 }
1591 1596
1592 1597 .empty_data {
1593 1598 color: @grey4;
1594 1599 }
1595 1600
1596 1601 #changeset_compare_view_content {
1597 1602 margin-bottom: @space;
1598 1603 clear: both;
1599 1604 width: 100%;
1600 1605 box-sizing: border-box;
1601 1606 .border-radius(@border-radius);
1602 1607
1603 1608 .help-block {
1604 1609 margin: @padding 0;
1605 1610 color: @text-color;
1606 1611 }
1607 1612
1608 1613 .empty_data {
1609 1614 margin: @padding 0;
1610 1615 }
1611 1616
1612 1617 .alert {
1613 1618 margin-bottom: @space;
1614 1619 }
1615 1620 }
1616 1621
1617 1622 .table_disp {
1618 1623 .status {
1619 1624 width: auto;
1620 1625
1621 1626 .flag_status {
1622 1627 float: left;
1623 1628 }
1624 1629 }
1625 1630 }
1626 1631
1627 1632 .status_box_menu {
1628 1633 margin: 0;
1629 1634 }
1630 1635
1631 1636 .notification-table{
1632 1637 margin-bottom: @space;
1633 1638 display: table;
1634 1639 width: 100%;
1635 1640
1636 1641 .container{
1637 1642 display: table-row;
1638 1643
1639 1644 .notification-header{
1640 1645 border-bottom: @border-thickness solid @border-default-color;
1641 1646 }
1642 1647
1643 1648 .notification-subject{
1644 1649 display: table-cell;
1645 1650 }
1646 1651 }
1647 1652 }
1648 1653
1649 1654 // Notifications
1650 1655 .notification-header{
1651 1656 display: table;
1652 1657 width: 100%;
1653 1658 padding: floor(@basefontsize/2) 0;
1654 1659 line-height: 1em;
1655 1660
1656 1661 .desc, .delete-notifications, .read-notifications{
1657 1662 display: table-cell;
1658 1663 text-align: left;
1659 1664 }
1660 1665
1661 1666 .desc{
1662 1667 width: 1163px;
1663 1668 }
1664 1669
1665 1670 .delete-notifications, .read-notifications{
1666 1671 width: 35px;
1667 1672 min-width: 35px; //fixes when only one button is displayed
1668 1673 }
1669 1674 }
1670 1675
1671 1676 .notification-body {
1672 1677 .markdown-block,
1673 1678 .rst-block {
1674 1679 padding: @padding 0;
1675 1680 }
1676 1681
1677 1682 .notification-subject {
1678 1683 padding: @textmargin 0;
1679 1684 border-bottom: @border-thickness solid @border-default-color;
1680 1685 }
1681 1686 }
1682 1687
1683 1688
1684 1689 .notifications_buttons{
1685 1690 float: right;
1686 1691 }
1687 1692
1688 1693 #notification-status{
1689 1694 display: inline;
1690 1695 }
1691 1696
1692 1697 // Repositories
1693 1698
1694 1699 #summary.fields{
1695 1700 display: table;
1696 1701
1697 1702 .field{
1698 1703 display: table-row;
1699 1704
1700 1705 .label-summary{
1701 1706 display: table-cell;
1702 1707 min-width: @label-summary-minwidth;
1703 1708 padding-top: @padding/2;
1704 1709 padding-bottom: @padding/2;
1705 1710 padding-right: @padding/2;
1706 1711 }
1707 1712
1708 1713 .input{
1709 1714 display: table-cell;
1710 1715 padding: @padding/2;
1711 1716
1712 1717 input{
1713 1718 min-width: 29em;
1714 1719 padding: @padding/4;
1715 1720 }
1716 1721 }
1717 1722 .statistics, .downloads{
1718 1723 .disabled{
1719 1724 color: @grey4;
1720 1725 }
1721 1726 }
1722 1727 }
1723 1728 }
1724 1729
1725 1730 #summary{
1726 1731 width: 70%;
1727 1732 }
1728 1733
1729 1734
1730 1735 // Journal
1731 1736 .journal.title {
1732 1737 h5 {
1733 1738 float: left;
1734 1739 margin: 0;
1735 1740 width: 70%;
1736 1741 }
1737 1742
1738 1743 ul {
1739 1744 float: right;
1740 1745 display: inline-block;
1741 1746 margin: 0;
1742 1747 width: 30%;
1743 1748 text-align: right;
1744 1749
1745 1750 li {
1746 1751 display: inline;
1747 1752 font-size: @journal-fontsize;
1748 1753 line-height: 1em;
1749 1754
1750 1755 &:before { content: none; }
1751 1756 }
1752 1757 }
1753 1758 }
1754 1759
1755 1760 .filterexample {
1756 1761 position: absolute;
1757 1762 top: 95px;
1758 1763 left: @contentpadding;
1759 1764 color: @rcblue;
1760 1765 font-size: 11px;
1761 1766 font-family: @text-regular;
1762 1767 cursor: help;
1763 1768
1764 1769 &:hover {
1765 1770 color: @rcdarkblue;
1766 1771 }
1767 1772
1768 1773 @media (max-width:768px) {
1769 1774 position: relative;
1770 1775 top: auto;
1771 1776 left: auto;
1772 1777 display: block;
1773 1778 }
1774 1779 }
1775 1780
1776 1781
1777 1782 #journal{
1778 1783 margin-bottom: @space;
1779 1784
1780 1785 .journal_day{
1781 1786 margin-bottom: @textmargin/2;
1782 1787 padding-bottom: @textmargin/2;
1783 1788 font-size: @journal-fontsize;
1784 1789 border-bottom: @border-thickness solid @border-default-color;
1785 1790 }
1786 1791
1787 1792 .journal_container{
1788 1793 margin-bottom: @space;
1789 1794
1790 1795 .journal_user{
1791 1796 display: inline-block;
1792 1797 }
1793 1798 .journal_action_container{
1794 1799 display: block;
1795 1800 margin-top: @textmargin;
1796 1801
1797 1802 div{
1798 1803 display: inline;
1799 1804 }
1800 1805
1801 1806 div.journal_action_params{
1802 1807 display: block;
1803 1808 }
1804 1809
1805 1810 div.journal_repo:after{
1806 1811 content: "\A";
1807 1812 white-space: pre;
1808 1813 }
1809 1814
1810 1815 div.date{
1811 1816 display: block;
1812 1817 margin-bottom: @textmargin;
1813 1818 }
1814 1819 }
1815 1820 }
1816 1821 }
1817 1822
1818 1823 // Files
1819 1824 .edit-file-title {
1820 1825 border-bottom: @border-thickness solid @border-default-color;
1821 1826
1822 1827 .breadcrumbs {
1823 1828 margin-bottom: 0;
1824 1829 }
1825 1830 }
1826 1831
1827 1832 .edit-file-fieldset {
1828 1833 margin-top: @sidebarpadding;
1829 1834
1830 1835 .fieldset {
1831 1836 .left-label {
1832 1837 width: 13%;
1833 1838 }
1834 1839 .right-content {
1835 1840 width: 87%;
1836 1841 max-width: 100%;
1837 1842 }
1838 1843 .filename-label {
1839 1844 margin-top: 13px;
1840 1845 }
1841 1846 .commit-message-label {
1842 1847 margin-top: 4px;
1843 1848 }
1844 1849 .file-upload-input {
1845 1850 input {
1846 1851 display: none;
1847 1852 }
1848 1853 }
1849 1854 p {
1850 1855 margin-top: 5px;
1851 1856 }
1852 1857
1853 1858 }
1854 1859 .custom-path-link {
1855 1860 margin-left: 5px;
1856 1861 }
1857 1862 #commit {
1858 1863 resize: vertical;
1859 1864 }
1860 1865 }
1861 1866
1862 1867 .delete-file-preview {
1863 1868 max-height: 250px;
1864 1869 }
1865 1870
1866 1871 .new-file,
1867 1872 #filter_activate,
1868 1873 #filter_deactivate {
1869 1874 float: left;
1870 1875 margin: 0 0 0 15px;
1871 1876 }
1872 1877
1873 1878 h3.files_location{
1874 1879 line-height: 2.4em;
1875 1880 }
1876 1881
1877 1882 .browser-nav {
1878 1883 display: table;
1879 1884 margin-bottom: @space;
1880 1885
1881 1886
1882 1887 .info_box {
1883 1888 display: inline-table;
1884 1889 height: 2.5em;
1885 1890
1886 1891 .browser-cur-rev, .info_box_elem {
1887 1892 display: table-cell;
1888 1893 vertical-align: middle;
1889 1894 }
1890 1895
1891 1896 .info_box_elem {
1892 1897 border-top: @border-thickness solid @rcblue;
1893 1898 border-bottom: @border-thickness solid @rcblue;
1894 1899
1895 1900 #at_rev, a {
1896 1901 padding: 0.6em 0.9em;
1897 1902 margin: 0;
1898 1903 .box-shadow(none);
1899 1904 border: 0;
1900 1905 height: 12px;
1901 1906 }
1902 1907
1903 1908 input#at_rev {
1904 1909 max-width: 50px;
1905 1910 text-align: right;
1906 1911 }
1907 1912
1908 1913 &.previous {
1909 1914 border: @border-thickness solid @rcblue;
1910 1915 .disabled {
1911 1916 color: @grey4;
1912 1917 cursor: not-allowed;
1913 1918 }
1914 1919 }
1915 1920
1916 1921 &.next {
1917 1922 border: @border-thickness solid @rcblue;
1918 1923 .disabled {
1919 1924 color: @grey4;
1920 1925 cursor: not-allowed;
1921 1926 }
1922 1927 }
1923 1928 }
1924 1929
1925 1930 .browser-cur-rev {
1926 1931
1927 1932 span{
1928 1933 margin: 0;
1929 1934 color: @rcblue;
1930 1935 height: 12px;
1931 1936 display: inline-block;
1932 1937 padding: 0.7em 1em ;
1933 1938 border: @border-thickness solid @rcblue;
1934 1939 margin-right: @padding;
1935 1940 }
1936 1941 }
1937 1942 }
1938 1943
1939 1944 .search_activate {
1940 1945 display: table-cell;
1941 1946 vertical-align: middle;
1942 1947
1943 1948 input, label{
1944 1949 margin: 0;
1945 1950 padding: 0;
1946 1951 }
1947 1952
1948 1953 input{
1949 1954 margin-left: @textmargin;
1950 1955 }
1951 1956
1952 1957 }
1953 1958 }
1954 1959
1955 1960 .browser-cur-rev{
1956 1961 margin-bottom: @textmargin;
1957 1962 }
1958 1963
1959 1964 #node_filter_box_loading{
1960 1965 .info_text;
1961 1966 }
1962 1967
1963 1968 .browser-search {
1964 1969 margin: -25px 0px 5px 0px;
1965 1970 }
1966 1971
1967 1972 .node-filter {
1968 1973 font-size: @repo-title-fontsize;
1969 1974 padding: 4px 0px 0px 0px;
1970 1975
1971 1976 .node-filter-path {
1972 1977 float: left;
1973 1978 color: @grey4;
1974 1979 }
1975 1980 .node-filter-input {
1976 1981 float: left;
1977 1982 margin: -2px 0px 0px 2px;
1978 1983 input {
1979 1984 padding: 2px;
1980 1985 border: none;
1981 1986 font-size: @repo-title-fontsize;
1982 1987 }
1983 1988 }
1984 1989 }
1985 1990
1986 1991
1987 1992 .browser-result{
1988 1993 td a{
1989 1994 margin-left: 0.5em;
1990 1995 display: inline-block;
1991 1996
1992 1997 em{
1993 1998 font-family: @text-bold;
1994 1999 }
1995 2000 }
1996 2001 }
1997 2002
1998 2003 .browser-highlight{
1999 2004 background-color: @grey5-alpha;
2000 2005 }
2001 2006
2002 2007
2003 2008 // Search
2004 2009
2005 2010 .search-form{
2006 2011 #q {
2007 2012 width: @search-form-width;
2008 2013 }
2009 2014 .fields{
2010 2015 margin: 0 0 @space;
2011 2016 }
2012 2017
2013 2018 label{
2014 2019 display: inline-block;
2015 2020 margin-right: @textmargin;
2016 2021 padding-top: 0.25em;
2017 2022 }
2018 2023
2019 2024
2020 2025 .results{
2021 2026 clear: both;
2022 2027 margin: 0 0 @padding;
2023 2028 }
2024 2029 }
2025 2030
2026 2031 div.search-feedback-items {
2027 2032 display: inline-block;
2028 2033 padding:0px 0px 0px 96px;
2029 2034 }
2030 2035
2031 2036 div.search-code-body {
2032 2037 background-color: #ffffff; padding: 5px 0 5px 10px;
2033 2038 pre {
2034 2039 .match { background-color: #faffa6;}
2035 2040 .break { display: block; width: 100%; background-color: #DDE7EF; color: #747474; }
2036 2041 }
2037 2042 }
2038 2043
2039 2044 .expand_commit.search {
2040 2045 .show_more.open {
2041 2046 height: auto;
2042 2047 max-height: none;
2043 2048 }
2044 2049 }
2045 2050
2046 2051 .search-results {
2047 2052
2048 2053 h2 {
2049 2054 margin-bottom: 0;
2050 2055 }
2051 2056 .codeblock {
2052 2057 border: none;
2053 2058 background: transparent;
2054 2059 }
2055 2060
2056 2061 .codeblock-header {
2057 2062 border: none;
2058 2063 background: transparent;
2059 2064 }
2060 2065
2061 2066 .code-body {
2062 2067 border: @border-thickness solid @border-default-color;
2063 2068 .border-radius(@border-radius);
2064 2069 }
2065 2070
2066 2071 .td-commit {
2067 2072 &:extend(pre);
2068 2073 border-bottom: @border-thickness solid @border-default-color;
2069 2074 }
2070 2075
2071 2076 .message {
2072 2077 height: auto;
2073 2078 max-width: 350px;
2074 2079 white-space: normal;
2075 2080 text-overflow: initial;
2076 2081 overflow: visible;
2077 2082
2078 2083 .match { background-color: #faffa6;}
2079 2084 .break { background-color: #DDE7EF; width: 100%; color: #747474; display: block; }
2080 2085 }
2081 2086
2082 2087 }
2083 2088
2084 2089 table.rctable td.td-search-results div {
2085 2090 max-width: 100%;
2086 2091 }
2087 2092
2088 2093 #tip-box, .tip-box{
2089 2094 padding: @menupadding/2;
2090 2095 display: block;
2091 2096 border: @border-thickness solid @border-highlight-color;
2092 2097 .border-radius(@border-radius);
2093 2098 background-color: white;
2094 2099 z-index: 99;
2095 2100 white-space: pre-wrap;
2096 2101 }
2097 2102
2098 2103 #linktt {
2099 2104 width: 79px;
2100 2105 }
2101 2106
2102 2107 #help_kb .modal-content{
2103 2108 max-width: 750px;
2104 2109 margin: 10% auto;
2105 2110
2106 2111 table{
2107 2112 td,th{
2108 2113 border-bottom: none;
2109 2114 line-height: 2.5em;
2110 2115 }
2111 2116 th{
2112 2117 padding-bottom: @textmargin/2;
2113 2118 }
2114 2119 td.keys{
2115 2120 text-align: center;
2116 2121 }
2117 2122 }
2118 2123
2119 2124 .block-left{
2120 2125 width: 45%;
2121 2126 margin-right: 5%;
2122 2127 }
2123 2128 .modal-footer{
2124 2129 clear: both;
2125 2130 }
2126 2131 .key.tag{
2127 2132 padding: 0.5em;
2128 2133 background-color: @rcblue;
2129 2134 color: white;
2130 2135 border-color: @rcblue;
2131 2136 .box-shadow(none);
2132 2137 }
2133 2138 }
2134 2139
2135 2140
2136 2141
2137 2142 //--- IMPORTS FOR REFACTORED STYLES ------------------//
2138 2143
2139 2144 @import 'statistics-graph';
2140 2145 @import 'tables';
2141 2146 @import 'forms';
2142 2147 @import 'diff';
2143 2148 @import 'summary';
2144 2149 @import 'navigation';
2145 2150
2146 2151 //--- SHOW/HIDE SECTIONS --//
2147 2152
2148 2153 .btn-collapse {
2149 2154 float: right;
2150 2155 text-align: right;
2151 2156 font-family: @text-light;
2152 2157 font-size: @basefontsize;
2153 2158 cursor: pointer;
2154 2159 border: none;
2155 2160 color: @rcblue;
2156 2161 }
2157 2162
2158 2163 table.rctable,
2159 2164 table.dataTable {
2160 2165 .btn-collapse {
2161 2166 float: right;
2162 2167 text-align: right;
2163 2168 }
2164 2169 }
2165 2170
2166 2171
2167 2172 // TODO: johbo: Fix for IE10, this avoids that we see a border
2168 2173 // and padding around checkboxes and radio boxes. Move to the right place,
2169 2174 // or better: Remove this once we did the form refactoring.
2170 2175 input[type=checkbox],
2171 2176 input[type=radio] {
2172 2177 padding: 0;
2173 2178 border: none;
2174 2179 }
2175 2180
2176 2181 .toggle-ajax-spinner{
2177 2182 height: 16px;
2178 2183 width: 16px;
2179 2184 }
@@ -1,621 +1,636 b''
1 1 <%inherit file="/base/base.html"/>
2 2
3 3 <%def name="title()">
4 4 ${_('%s Pull Request #%s') % (c.repo_name, c.pull_request.pull_request_id)}
5 5 %if c.rhodecode_name:
6 6 &middot; ${h.branding(c.rhodecode_name)}
7 7 %endif
8 8 </%def>
9 9
10 10 <%def name="breadcrumbs_links()">
11 11 <span id="pr-title">
12 12 ${c.pull_request.title}
13 13 %if c.pull_request.is_closed():
14 14 (${_('Closed')})
15 15 %endif
16 16 </span>
17 17 <div id="pr-title-edit" class="input" style="display: none;">
18 18 ${h.text('pullrequest_title', id_="pr-title-input", class_="large", value=c.pull_request.title)}
19 19 </div>
20 20 </%def>
21 21
22 22 <%def name="menu_bar_nav()">
23 23 ${self.menu_items(active='repositories')}
24 24 </%def>
25 25
26 26 <%def name="menu_bar_subnav()">
27 27 ${self.repo_menu(active='showpullrequest')}
28 28 </%def>
29 29
30 30 <%def name="main()">
31 31 <script type="text/javascript">
32 32 // TODO: marcink switch this to pyroutes
33 33 AJAX_COMMENT_DELETE_URL = "${url('pullrequest_comment_delete',repo_name=c.repo_name,comment_id='__COMMENT_ID__')}";
34 34 templateContext.pull_request_data.pull_request_id = ${c.pull_request.pull_request_id};
35 35 </script>
36 36 <div class="box">
37 37 <div class="title">
38 38 ${self.repo_page_title(c.rhodecode_db_repo)}
39 39 </div>
40 40
41 41 ${self.breadcrumbs()}
42 42
43 43
44 44 <div class="box pr-summary">
45 45 <div class="summary-details block-left">
46 46 <%summary = lambda n:{False:'summary-short'}.get(n)%>
47 47 <div class="pr-details-title">
48 48 ${_('Pull request #%s') % c.pull_request.pull_request_id} ${_('From')} ${h.format_date(c.pull_request.created_on)}
49 49 %if c.allowed_to_update:
50 <span id="open_edit_pullrequest" class="block-right action_button">${_('Edit')}</span>
51 <span id="close_edit_pullrequest" class="block-right action_button" style="display: none;">${_('Close')}</span>
50 <div id="delete_pullrequest" class="pull-right action_button ${'' if c.allowed_to_delete else 'disabled' }" style="clear:inherit;padding: 0">
51 % if c.allowed_to_delete:
52 ${h.secure_form(url('pullrequest_delete', repo_name=c.pull_request.target_repo.repo_name, pull_request_id=c.pull_request.pull_request_id),method='delete')}
53 ${h.submit('remove_%s' % c.pull_request.pull_request_id, _('Delete'),
54 class_="btn btn-link btn-danger",onclick="return confirm('"+_('Confirm to delete this pull request')+"');")}
55 ${h.end_form()}
56 % else:
57 ${_('Delete')}
58 % endif
59 </div>
60 <div id="open_edit_pullrequest" class="pull-right action_button">${_('Edit')}</div>
61 <div id="close_edit_pullrequest" class="pull-right action_button" style="display: none;padding: 0">${_('Cancel edit')}</div>
52 62 %endif
53 63 </div>
54 64
55 65 <div id="summary" class="fields pr-details-content">
56 66 <div class="field">
57 67 <div class="label-summary">
58 68 <label>${_('Origin')}:</label>
59 69 </div>
60 70 <div class="input">
61 71 <div class="pr-origininfo">
62 72 ## branch link is only valid if it is a branch
63 73 <span class="tag">
64 74 %if c.pull_request.source_ref_parts.type == 'branch':
65 75 <a href="${h.url('changelog_home', repo_name=c.pull_request.source_repo.repo_name, branch=c.pull_request.source_ref_parts.name)}">${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}</a>
66 76 %else:
67 77 ${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}
68 78 %endif
69 79 </span>
70 80 <span class="clone-url">
71 81 <a href="${h.url('summary_home', repo_name=c.pull_request.source_repo.repo_name)}">${c.pull_request.source_repo.clone_url()}</a>
72 82 </span>
73 83 </div>
74 84 <div class="pr-pullinfo">
75 85 %if h.is_hg(c.pull_request.source_repo):
76 86 <input type="text" value="hg pull -r ${h.short_id(c.source_ref)} ${c.pull_request.source_repo.clone_url()}" readonly="readonly">
77 87 %elif h.is_git(c.pull_request.source_repo):
78 88 <input type="text" value="git pull ${c.pull_request.source_repo.clone_url()} ${c.pull_request.source_ref_parts.name}" readonly="readonly">
79 89 %endif
80 90 </div>
81 91 </div>
82 92 </div>
83 93 <div class="field">
84 94 <div class="label-summary">
85 95 <label>${_('Target')}:</label>
86 96 </div>
87 97 <div class="input">
88 98 <div class="pr-targetinfo">
89 99 ## branch link is only valid if it is a branch
90 100 <span class="tag">
91 101 %if c.pull_request.target_ref_parts.type == 'branch':
92 102 <a href="${h.url('changelog_home', repo_name=c.pull_request.target_repo.repo_name, branch=c.pull_request.target_ref_parts.name)}">${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}</a>
93 103 %else:
94 104 ${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}
95 105 %endif
96 106 </span>
97 107 <span class="clone-url">
98 108 <a href="${h.url('summary_home', repo_name=c.pull_request.target_repo.repo_name)}">${c.pull_request.target_repo.clone_url()}</a>
99 109 </span>
100 110 </div>
101 111 </div>
102 112 </div>
103 113
104 114 ## Link to the shadow repository.
105 115 %if not c.pull_request.is_closed() and c.pull_request.shadow_merge_ref:
106 116 <div class="field">
107 117 <div class="label-summary">
108 118 <label>Merge:</label>
109 119 </div>
110 120 <div class="input">
111 121 <div class="pr-mergeinfo">
112 122 %if h.is_hg(c.pull_request.target_repo):
113 123 <input type="text" value="hg clone -u ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
114 124 %elif h.is_git(c.pull_request.target_repo):
115 125 <input type="text" value="git clone --branch ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
116 126 %endif
117 127 </div>
118 128 </div>
119 129 </div>
120 130 %endif
121 131
122 132 <div class="field">
123 133 <div class="label-summary">
124 134 <label>${_('Review')}:</label>
125 135 </div>
126 136 <div class="input">
127 137 %if c.pull_request_review_status:
128 138 <div class="${'flag_status %s' % c.pull_request_review_status} tooltip pull-left"></div>
129 139 <span class="changeset-status-lbl tooltip">
130 140 %if c.pull_request.is_closed():
131 141 ${_('Closed')},
132 142 %endif
133 143 ${h.commit_status_lbl(c.pull_request_review_status)}
134 144 </span>
135 145 - ${ungettext('calculated based on %s reviewer vote', 'calculated based on %s reviewers votes', len(c.pull_request_reviewers)) % len(c.pull_request_reviewers)}
136 146 %endif
137 147 </div>
138 148 </div>
139 149 <div class="field">
140 150 <div class="pr-description-label label-summary">
141 151 <label>${_('Description')}:</label>
142 152 </div>
143 153 <div id="pr-desc" class="input">
144 154 <div class="pr-description">${h.urlify_commit_message(c.pull_request.description, c.repo_name)}</div>
145 155 </div>
146 156 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
147 157 <textarea id="pr-description-input" size="30">${c.pull_request.description}</textarea>
148 158 </div>
149 159 </div>
150 160 <div class="field">
151 161 <div class="label-summary">
152 162 <label>${_('Comments')}:</label>
153 163 </div>
154 164 <div class="input">
155 165 <div>
156 166 <div class="comments-number">
157 167 %if c.comments:
158 168 <a href="#comments">${ungettext("%d Pull request comment", "%d Pull request comments", len(c.comments)) % len(c.comments)}</a>,
159 169 %else:
160 170 ${ungettext("%d Pull request comment", "%d Pull request comments", len(c.comments)) % len(c.comments)}
161 171 %endif
162 172 %if c.inline_cnt:
163 173 ## this is replaced with a proper link to first comment via JS linkifyComments() func
164 174 <a href="#inline-comments" id="inline-comments-counter">${ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}</a>
165 175 %else:
166 176 ${ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}
167 177 %endif
168 178
169 179 % if c.outdated_cnt:
170 180 ,${ungettext("%d Outdated Comment", "%d Outdated Comments", c.outdated_cnt) % c.outdated_cnt} <span id="show-outdated-comments" class="btn btn-link">${_('(Show)')}</span>
171 181 % endif
172 182 </div>
173 183 </div>
174 184 </div>
175 185 </div>
176 186 <div id="pr-save" class="field" style="display: none;">
177 187 <div class="label-summary"></div>
178 188 <div class="input">
179 189 <span id="edit_pull_request" class="btn btn-small">${_('Save Changes')}</span>
180 190 </div>
181 191 </div>
182 192 </div>
183 193 </div>
184 194 <div>
185 195 ## AUTHOR
186 196 <div class="reviewers-title block-right">
187 197 <div class="pr-details-title">
188 198 ${_('Author')}
189 199 </div>
190 200 </div>
191 201 <div class="block-right pr-details-content reviewers">
192 202 <ul class="group_members">
193 203 <li>
194 204 ${self.gravatar_with_user(c.pull_request.author.email, 16)}
195 205 </li>
196 206 </ul>
197 207 </div>
198 208 ## REVIEWERS
199 209 <div class="reviewers-title block-right">
200 210 <div class="pr-details-title">
201 211 ${_('Pull request reviewers')}
202 212 %if c.allowed_to_update:
203 213 <span id="open_edit_reviewers" class="block-right action_button">${_('Edit')}</span>
204 214 <span id="close_edit_reviewers" class="block-right action_button" style="display: none;">${_('Close')}</span>
205 215 %endif
206 216 </div>
207 217 </div>
208 218 <div id="reviewers" class="block-right pr-details-content reviewers">
209 219 ## members goes here !
210 220 <input type="hidden" name="__start__" value="review_members:sequence">
211 221 <ul id="review_members" class="group_members">
212 222 %for member,reasons,status in c.pull_request_reviewers:
213 223 <li id="reviewer_${member.user_id}">
214 224 <div class="reviewers_member">
215 225 <div class="reviewer_status tooltip" title="${h.tooltip(h.commit_status_lbl(status[0][1].status if status else 'not_reviewed'))}">
216 226 <div class="${'flag_status %s' % (status[0][1].status if status else 'not_reviewed')} pull-left reviewer_member_status"></div>
217 227 </div>
218 228 <div id="reviewer_${member.user_id}_name" class="reviewer_name">
219 229 ${self.gravatar_with_user(member.email, 16)}
220 230 </div>
221 231 <input type="hidden" name="__start__" value="reviewer:mapping">
222 232 <input type="hidden" name="__start__" value="reasons:sequence">
223 233 %for reason in reasons:
224 234 <div class="reviewer_reason">- ${reason}</div>
225 235 <input type="hidden" name="reason" value="${reason}">
226 236
227 237 %endfor
228 238 <input type="hidden" name="__end__" value="reasons:sequence">
229 239 <input id="reviewer_${member.user_id}_input" type="hidden" value="${member.user_id}" name="user_id" />
230 240 <input type="hidden" name="__end__" value="reviewer:mapping">
231 241 %if c.allowed_to_update:
232 242 <div class="reviewer_member_remove action_button" onclick="removeReviewMember(${member.user_id}, true)" style="visibility: hidden;">
233 243 <i class="icon-remove-sign" ></i>
234 244 </div>
235 245 %endif
236 246 </div>
237 247 </li>
238 248 %endfor
239 249 </ul>
240 250 <input type="hidden" name="__end__" value="review_members:sequence">
241 251 %if not c.pull_request.is_closed():
242 252 <div id="add_reviewer_input" class='ac' style="display: none;">
243 253 %if c.allowed_to_update:
244 254 <div class="reviewer_ac">
245 255 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer'))}
246 256 <div id="reviewers_container"></div>
247 257 </div>
248 258 <div>
249 259 <span id="update_pull_request" class="btn btn-small">${_('Save Changes')}</span>
250 260 </div>
251 261 %endif
252 262 </div>
253 263 %endif
254 264 </div>
255 265 </div>
256 266 </div>
257 267 <div class="box">
258 268 ##DIFF
259 269 <div class="table" >
260 270 <div id="changeset_compare_view_content">
261 271 ##CS
262 272 % if c.missing_requirements:
263 273 <div class="box">
264 274 <div class="alert alert-warning">
265 275 <div>
266 276 <strong>${_('Missing requirements:')}</strong>
267 277 ${_('These commits cannot be displayed, because this repository uses the Mercurial largefiles extension, which was not enabled.')}
268 278 </div>
269 279 </div>
270 280 </div>
271 281 % elif c.missing_commits:
272 282 <div class="box">
273 283 <div class="alert alert-warning">
274 284 <div>
275 285 <strong>${_('Missing commits')}:</strong>
276 286 ${_('This pull request cannot be displayed, because one or more commits no longer exist in the source repository.')}
277 287 ${_('Please update this pull request, push the commits back into the source repository, or consider closing this pull request.')}
278 288 </div>
279 289 </div>
280 290 </div>
281 291 % endif
282 292 <div class="compare_view_commits_title">
283 293 % if c.allowed_to_update and not c.pull_request.is_closed():
284 294 <button id="update_commits" class="btn btn-small">${_('Update commits')}</button>
285 295 % endif
286 296 % if len(c.commit_ranges):
287 297 <h2>${ungettext('Compare View: %s commit','Compare View: %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}</h2>
288 298 % endif
289 299 </div>
290 300 % if not c.missing_commits:
291 301 <%include file="/compare/compare_commits.html" />
292 302 ## FILES
293 303 <div class="cs_files_title">
294 304 <span class="cs_files_expand">
295 305 <span id="expand_all_files">${_('Expand All')}</span> | <span id="collapse_all_files">${_('Collapse All')}</span>
296 306 </span>
297 307 <h2>
298 308 ${diff_block.diff_summary_text(len(c.files), c.lines_added, c.lines_deleted, c.limited_diff)}
299 309 </h2>
300 310 </div>
301 311 % endif
302 312 <div class="cs_files">
303 313 %if not c.files and not c.missing_commits:
304 314 <span class="empty_data">${_('No files')}</span>
305 315 %endif
306 316 <table class="compare_view_files">
307 317 <%namespace name="diff_block" file="/changeset/diff_block.html"/>
308 318 %for FID, change, path, stats in c.files:
309 319 <tr class="cs_${change} collapse_file" fid="${FID}">
310 320 <td class="cs_icon_td">
311 321 <span class="collapse_file_icon" fid="${FID}"></span>
312 322 </td>
313 323 <td class="cs_icon_td">
314 324 <div class="flag_status not_reviewed hidden"></div>
315 325 </td>
316 326 <td class="cs_${change}" id="a_${FID}">
317 327 <div class="node">
318 328 <a href="#a_${FID}">
319 329 <i class="icon-file-${change.lower()}"></i>
320 330 ${h.safe_unicode(path)}
321 331 </a>
322 332 </div>
323 333 </td>
324 334 <td>
325 335 <div class="changes pull-right">${h.fancy_file_stats(stats)}</div>
326 336 <div class="comment-bubble pull-right" data-path="${path}">
327 337 <i class="icon-comment"></i>
328 338 </div>
329 339 </td>
330 340 </tr>
331 341 <tr fid="${FID}" id="diff_${FID}" class="diff_links">
332 342 <td></td>
333 343 <td></td>
334 344 <td class="cs_${change}">
335 345 %if c.target_repo.repo_name == c.repo_name:
336 346 ${diff_block.diff_menu(c.repo_name, h.safe_unicode(path), c.target_ref, c.source_ref, change)}
337 347 %else:
338 348 ## this is slightly different case later, since the other repo can have this
339 349 ## file in other state than the origin repo
340 350 ${diff_block.diff_menu(c.target_repo.repo_name, h.safe_unicode(path), c.target_ref, c.source_ref, change)}
341 351 %endif
342 352 </td>
343 353 <td class="td-actions rc-form">
344 354 <div data-comment-id="${FID}" class="btn-link show-inline-comments comments-visible">
345 355 <span class="comments-show">${_('Show comments')}</span>
346 356 <span class="comments-hide">${_('Hide comments')}</span>
347 357 </div>
348 358 </td>
349 359 </tr>
350 360 <tr id="tr_${FID}">
351 361 <td></td>
352 362 <td></td>
353 363 <td class="injected_diff" colspan="2">
354 364 ${diff_block.diff_block_simple([c.changes[FID]])}
355 365 </td>
356 366 </tr>
357 367
358 368 ## Loop through inline comments
359 369 % if c.outdated_comments.get(path,False):
360 370 <tr class="outdated">
361 371 <td></td>
362 372 <td></td>
363 373 <td colspan="2">
364 374 <p>${_('Outdated Inline Comments')}:</p>
365 375 </td>
366 376 </tr>
367 377 <tr class="outdated">
368 378 <td></td>
369 379 <td></td>
370 380 <td colspan="2" class="outdated_comment_block">
371 381 % for line, comments in c.outdated_comments[path].iteritems():
372 382 <div class="inline-comment-placeholder" path="${path}" target_id="${h.safeid(h.safe_unicode(path))}">
373 383 % for co in comments:
374 384 ${comment.comment_block_outdated(co)}
375 385 % endfor
376 386 </div>
377 387 % endfor
378 388 </td>
379 389 </tr>
380 390 % endif
381 391 %endfor
382 392 ## Loop through inline comments for deleted files
383 393 %for path in c.deleted_files:
384 394 <tr class="outdated deleted">
385 395 <td></td>
386 396 <td></td>
387 397 <td>${path}</td>
388 398 </tr>
389 399 <tr class="outdated deleted">
390 400 <td></td>
391 401 <td></td>
392 402 <td>(${_('Removed')})</td>
393 403 </tr>
394 404 % if path in c.outdated_comments:
395 405 <tr class="outdated deleted">
396 406 <td></td>
397 407 <td></td>
398 408 <td colspan="2">
399 409 <p>${_('Outdated Inline Comments')}:</p>
400 410 </td>
401 411 </tr>
402 412 <tr class="outdated">
403 413 <td></td>
404 414 <td></td>
405 415 <td colspan="2" class="outdated_comment_block">
406 416 % for line, comments in c.outdated_comments[path].iteritems():
407 417 <div class="inline-comment-placeholder" path="${path}" target_id="${h.safeid(h.safe_unicode(path))}">
408 418 % for co in comments:
409 419 ${comment.comment_block_outdated(co)}
410 420 % endfor
411 421 </div>
412 422 % endfor
413 423 </td>
414 424 </tr>
415 425 % endif
416 426 %endfor
417 427 </table>
418 428 </div>
419 429 % if c.limited_diff:
420 430 <h5>${_('Commit was too big and was cut off...')} <a href="${h.url.current(fulldiff=1, **request.GET.mixed())}" onclick="return confirm('${_("Showing a huge diff might take some time and resources")}')">${_('Show full diff')}</a></h5>
421 431 % endif
422 432 </div>
423 433 </div>
424 434
425 435 % if c.limited_diff:
426 436 <p>${_('Commit was too big and was cut off...')} <a href="${h.url.current(fulldiff=1, **request.GET.mixed())}" onclick="return confirm('${_("Showing a huge diff might take some time and resources")}')">${_('Show full diff')}</a></p>
427 437 % endif
428 438
429 439 ## template for inline comment form
430 440 <%namespace name="comment" file="/changeset/changeset_file_comment.html"/>
431 441 ${comment.comment_inline_form()}
432 442
433 443 ## render comments and inlines
434 444 ${comment.generate_comments(include_pull_request=True, is_pull_request=True)}
435 445
436 446 % if not c.pull_request.is_closed():
437 447 ## main comment form and it status
438 448 ${comment.comments(h.url('pullrequest_comment', repo_name=c.repo_name,
439 449 pull_request_id=c.pull_request.pull_request_id),
440 450 c.pull_request_review_status,
441 451 is_pull_request=True, change_status=c.allowed_to_change_status)}
442 452 %endif
443 453
444 454 <script type="text/javascript">
445 455 if (location.hash) {
446 456 var result = splitDelimitedHash(location.hash);
447 457 var line = $('html').find(result.loc);
448 458 if (line.length > 0){
449 459 offsetScroll(line, 70);
450 460 }
451 461 }
452 462 $(function(){
453 463 ReviewerAutoComplete('user');
454 464 // custom code mirror
455 465 var codeMirrorInstance = initPullRequestsCodeMirror('#pr-description-input');
456 466
457 467 var PRDetails = {
458 468 editButton: $('#open_edit_pullrequest'),
459 469 closeButton: $('#close_edit_pullrequest'),
470 deleteButton: $('#delete_pullrequest'),
460 471 viewFields: $('#pr-desc, #pr-title'),
461 472 editFields: $('#pr-desc-edit, #pr-title-edit, #pr-save'),
462 473
463 474 init: function() {
464 475 var that = this;
465 476 this.editButton.on('click', function(e) { that.edit(); });
466 477 this.closeButton.on('click', function(e) { that.view(); });
467 478 },
468 479
469 480 edit: function(event) {
470 481 this.viewFields.hide();
471 482 this.editButton.hide();
483 this.deleteButton.hide();
484 this.closeButton.show();
472 485 this.editFields.show();
473 486 codeMirrorInstance.refresh();
474 487 },
475 488
476 489 view: function(event) {
490 this.editButton.show();
491 this.deleteButton.show();
477 492 this.editFields.hide();
478 493 this.closeButton.hide();
479 494 this.viewFields.show();
480 495 }
481 496 };
482 497
483 498 var ReviewersPanel = {
484 499 editButton: $('#open_edit_reviewers'),
485 500 closeButton: $('#close_edit_reviewers'),
486 501 addButton: $('#add_reviewer_input'),
487 502 removeButtons: $('.reviewer_member_remove'),
488 503
489 504 init: function() {
490 505 var that = this;
491 506 this.editButton.on('click', function(e) { that.edit(); });
492 507 this.closeButton.on('click', function(e) { that.close(); });
493 508 },
494 509
495 510 edit: function(event) {
496 511 this.editButton.hide();
497 512 this.closeButton.show();
498 513 this.addButton.show();
499 514 this.removeButtons.css('visibility', 'visible');
500 515 },
501 516
502 517 close: function(event) {
503 518 this.editButton.show();
504 519 this.closeButton.hide();
505 520 this.addButton.hide();
506 521 this.removeButtons.css('visibility', 'hidden');
507 }
522 },
508 523 };
509 524
510 525 PRDetails.init();
511 526 ReviewersPanel.init();
512 527
513 528 $('#show-outdated-comments').on('click', function(e){
514 529 var button = $(this);
515 530 var outdated = $('.outdated');
516 531 if (button.html() === "(Show)") {
517 532 button.html("(Hide)");
518 533 outdated.show();
519 534 } else {
520 535 button.html("(Show)");
521 536 outdated.hide();
522 537 }
523 538 });
524 539
525 540 $('.show-inline-comments').on('change', function(e){
526 541 var show = 'none';
527 542 var target = e.currentTarget;
528 543 if(target.checked){
529 544 show = ''
530 545 }
531 546 var boxid = $(target).attr('id_for');
532 547 var comments = $('#{0} .inline-comments'.format(boxid));
533 548 var fn_display = function(idx){
534 549 $(this).css('display', show);
535 550 };
536 551 $(comments).each(fn_display);
537 552 var btns = $('#{0} .inline-comments-button'.format(boxid));
538 553 $(btns).each(fn_display);
539 554 });
540 555
541 556 // inject comments into their proper positions
542 557 var file_comments = $('.inline-comment-placeholder');
543 558 %if c.pull_request.is_closed():
544 559 renderInlineComments(file_comments, false);
545 560 %else:
546 561 renderInlineComments(file_comments, true);
547 562 %endif
548 563 var commentTotals = {};
549 564 $.each(file_comments, function(i, comment) {
550 565 var path = $(comment).attr('path');
551 566 var comms = $(comment).children().length;
552 567 if (path in commentTotals) {
553 568 commentTotals[path] += comms;
554 569 } else {
555 570 commentTotals[path] = comms;
556 571 }
557 572 });
558 573 $.each(commentTotals, function(path, total) {
559 574 var elem = $('.comment-bubble[data-path="'+ path +'"]');
560 575 elem.css('visibility', 'visible');
561 576 elem.html(elem.html() + ' ' + total );
562 577 });
563 578
564 579 $('#merge_pull_request_form').submit(function() {
565 580 if (!$('#merge_pull_request').attr('disabled')) {
566 581 $('#merge_pull_request').attr('disabled', 'disabled');
567 582 }
568 583 return true;
569 584 });
570 585
571 586 $('#edit_pull_request').on('click', function(e){
572 587 var title = $('#pr-title-input').val();
573 588 var description = codeMirrorInstance.getValue();
574 589 editPullRequest(
575 590 "${c.repo_name}", "${c.pull_request.pull_request_id}",
576 591 title, description);
577 592 });
578 593
579 594 $('#update_pull_request').on('click', function(e){
580 595 updateReviewers(undefined, "${c.repo_name}", "${c.pull_request.pull_request_id}");
581 596 });
582 597
583 598 $('#update_commits').on('click', function(e){
584 599 var isDisabled = !$(e.currentTarget).attr('disabled');
585 600 $(e.currentTarget).text(_gettext('Updating...'));
586 601 $(e.currentTarget).attr('disabled', 'disabled');
587 602 if(isDisabled){
588 603 updateCommits("${c.repo_name}", "${c.pull_request.pull_request_id}");
589 604 }
590 605
591 606 });
592 607 // fixing issue with caches on firefox
593 608 $('#update_commits').removeAttr("disabled");
594 609
595 610 $('#close_pull_request').on('click', function(e){
596 611 closePullRequest("${c.repo_name}", "${c.pull_request.pull_request_id}");
597 612 });
598 613
599 614 $('.show-inline-comments').on('click', function(e){
600 615 var boxid = $(this).attr('data-comment-id');
601 616 var button = $(this);
602 617
603 618 if(button.hasClass("comments-visible")) {
604 619 $('#{0} .inline-comments'.format(boxid)).each(function(index){
605 620 $(this).hide();
606 })
621 });
607 622 button.removeClass("comments-visible");
608 623 } else {
609 624 $('#{0} .inline-comments'.format(boxid)).each(function(index){
610 625 $(this).show();
611 })
626 });
612 627 button.addClass("comments-visible");
613 628 }
614 629 });
615 630 })
616 631 </script>
617 632
618 633 </div>
619 634 </div>
620 635
621 636 </%def>
@@ -1,42 +1,42 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22
23 23 from rhodecode.controllers import pullrequests
24 24 from rhodecode.lib.vcs.backends.base import (
25 25 MergeFailureReason, MergeResponse)
26 26 from rhodecode.model.pull_request import PullRequestModel
27 27 from rhodecode.tests import assert_session_flash
28 28
29 29
30 def test_merge_pull_request_renders_failure_reason(user_regular):
30 def test_merge_pull_request_renders_failure_reason(app, user_regular):
31 31 pull_request = mock.Mock()
32 32 controller = pullrequests.PullrequestsController()
33 33 model_patcher = mock.patch.multiple(
34 34 PullRequestModel,
35 35 merge=mock.Mock(return_value=MergeResponse(
36 36 True, False, 'STUB_COMMIT_ID', MergeFailureReason.PUSH_FAILED)),
37 37 merge_status=mock.Mock(return_value=(True, 'WRONG_MESSAGE')))
38 38 with model_patcher:
39 39 controller._merge_pull_request(pull_request, user_regular, extras={})
40 40
41 41 assert_session_flash(msg=PullRequestModel.MERGE_STATUS_MESSAGES[
42 42 MergeFailureReason.PUSH_FAILED])
@@ -1,1001 +1,1064 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 from webob.exc import HTTPNotFound
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.vcs.nodes import FileNode
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 from rhodecode.model.repo import RepoModel
33 34 from rhodecode.tests import assert_session_flash, url, TEST_USER_ADMIN_LOGIN
34 35 from rhodecode.tests.utils import AssertResponse
35 36
36 37
37 38 @pytest.mark.usefixtures('app', 'autologin_user')
38 39 @pytest.mark.backends("git", "hg")
39 40 class TestPullrequestsController:
40 41
41 42 def test_index(self, backend):
42 43 self.app.get(url(
43 44 controller='pullrequests', action='index',
44 45 repo_name=backend.repo_name))
45 46
46 47 def test_option_menu_create_pull_request_exists(self, backend):
47 48 repo_name = backend.repo_name
48 49 response = self.app.get(url('summary_home', repo_name=repo_name))
49 50
50 51 create_pr_link = '<a href="%s">Create Pull Request</a>' % url(
51 52 'pullrequest', repo_name=repo_name)
52 53 response.mustcontain(create_pr_link)
53 54
54 55 def test_global_redirect_of_pr(self, backend, pr_util):
55 56 pull_request = pr_util.create_pull_request()
56 57
57 58 response = self.app.get(
58 59 url('pull_requests_global',
59 60 pull_request_id=pull_request.pull_request_id))
60 61
61 62 repo_name = pull_request.target_repo.repo_name
62 63 redirect_url = url('pullrequest_show', repo_name=repo_name,
63 64 pull_request_id=pull_request.pull_request_id)
64 65 assert response.status == '302 Found'
65 66 assert redirect_url in response.location
66 67
67 68 def test_create_pr_form_with_raw_commit_id(self, backend):
68 69 repo = backend.repo
69 70
70 71 self.app.get(
71 72 url(controller='pullrequests', action='index',
72 73 repo_name=repo.repo_name,
73 74 commit=repo.get_commit().raw_id),
74 75 status=200)
75 76
76 77 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
77 78 def test_show(self, pr_util, pr_merge_enabled):
78 79 pull_request = pr_util.create_pull_request(
79 80 mergeable=pr_merge_enabled, enable_notifications=False)
80 81
81 82 response = self.app.get(url(
82 83 controller='pullrequests', action='show',
83 84 repo_name=pull_request.target_repo.scm_instance().name,
84 85 pull_request_id=str(pull_request.pull_request_id)))
85 86
86 87 for commit_id in pull_request.revisions:
87 88 response.mustcontain(commit_id)
88 89
89 90 assert pull_request.target_ref_parts.type in response
90 91 assert pull_request.target_ref_parts.name in response
91 92 target_clone_url = pull_request.target_repo.clone_url()
92 93 assert target_clone_url in response
93 94
94 95 assert 'class="pull-request-merge"' in response
95 96 assert (
96 97 'Server-side pull request merging is disabled.'
97 98 in response) != pr_merge_enabled
98 99
99 100 def test_close_status_visibility(self, pr_util, csrf_token):
100 101 from rhodecode.tests.functional.test_login import login_url, logut_url
101 102 # Logout
102 103 response = self.app.post(
103 104 logut_url,
104 105 params={'csrf_token': csrf_token})
105 106 # Login as regular user
106 107 response = self.app.post(login_url,
107 108 {'username': 'test_regular',
108 109 'password': 'test12'})
109 110
110 111 pull_request = pr_util.create_pull_request(author='test_regular')
111 112
112 113 response = self.app.get(url(
113 114 controller='pullrequests', action='show',
114 115 repo_name=pull_request.target_repo.scm_instance().name,
115 116 pull_request_id=str(pull_request.pull_request_id)))
116 117
117 118 assert 'Server-side pull request merging is disabled.' in response
118 119 assert 'value="forced_closed"' in response
119 120
120 121 def test_show_invalid_commit_id(self, pr_util):
121 122 # Simulating invalid revisions which will cause a lookup error
122 123 pull_request = pr_util.create_pull_request()
123 124 pull_request.revisions = ['invalid']
124 125 Session().add(pull_request)
125 126 Session().commit()
126 127
127 128 response = self.app.get(url(
128 129 controller='pullrequests', action='show',
129 130 repo_name=pull_request.target_repo.scm_instance().name,
130 131 pull_request_id=str(pull_request.pull_request_id)))
131 132
132 133 for commit_id in pull_request.revisions:
133 134 response.mustcontain(commit_id)
134 135
135 136 def test_show_invalid_source_reference(self, pr_util):
136 137 pull_request = pr_util.create_pull_request()
137 138 pull_request.source_ref = 'branch:b:invalid'
138 139 Session().add(pull_request)
139 140 Session().commit()
140 141
141 142 self.app.get(url(
142 143 controller='pullrequests', action='show',
143 144 repo_name=pull_request.target_repo.scm_instance().name,
144 145 pull_request_id=str(pull_request.pull_request_id)))
145 146
146 147 def test_edit_title_description(self, pr_util, csrf_token):
147 148 pull_request = pr_util.create_pull_request()
148 149 pull_request_id = pull_request.pull_request_id
149 150
150 151 response = self.app.post(
151 152 url(controller='pullrequests', action='update',
152 153 repo_name=pull_request.target_repo.repo_name,
153 154 pull_request_id=str(pull_request_id)),
154 155 params={
155 156 'edit_pull_request': 'true',
156 157 '_method': 'put',
157 158 'title': 'New title',
158 159 'description': 'New description',
159 160 'csrf_token': csrf_token})
160 161
161 162 assert_session_flash(
162 163 response, u'Pull request title & description updated.',
163 164 category='success')
164 165
165 166 pull_request = PullRequest.get(pull_request_id)
166 167 assert pull_request.title == 'New title'
167 168 assert pull_request.description == 'New description'
168 169
169 170 def test_edit_title_description_closed(self, pr_util, csrf_token):
170 171 pull_request = pr_util.create_pull_request()
171 172 pull_request_id = pull_request.pull_request_id
172 173 pr_util.close()
173 174
174 175 response = self.app.post(
175 176 url(controller='pullrequests', action='update',
176 177 repo_name=pull_request.target_repo.repo_name,
177 178 pull_request_id=str(pull_request_id)),
178 179 params={
179 180 'edit_pull_request': 'true',
180 181 '_method': 'put',
181 182 'title': 'New title',
182 183 'description': 'New description',
183 184 'csrf_token': csrf_token})
184 185
185 186 assert_session_flash(
186 187 response, u'Cannot update closed pull requests.',
187 188 category='error')
188 189
189 190 def test_update_invalid_source_reference(self, pr_util, csrf_token):
190 191 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
191 192
192 193 pull_request = pr_util.create_pull_request()
193 194 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
194 195 Session().add(pull_request)
195 196 Session().commit()
196 197
197 198 pull_request_id = pull_request.pull_request_id
198 199
199 200 response = self.app.post(
200 201 url(controller='pullrequests', action='update',
201 202 repo_name=pull_request.target_repo.repo_name,
202 203 pull_request_id=str(pull_request_id)),
203 204 params={'update_commits': 'true', '_method': 'put',
204 205 'csrf_token': csrf_token})
205 206
206 207 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
207 208 UpdateFailureReason.MISSING_SOURCE_REF]
208 209 assert_session_flash(response, expected_msg, category='error')
209 210
210 211 def test_missing_target_reference(self, pr_util, csrf_token):
211 212 from rhodecode.lib.vcs.backends.base import MergeFailureReason
212 213 pull_request = pr_util.create_pull_request(
213 214 approved=True, mergeable=True)
214 215 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
215 216 Session().add(pull_request)
216 217 Session().commit()
217 218
218 219 pull_request_id = pull_request.pull_request_id
219 220 pull_request_url = url(
220 221 controller='pullrequests', action='show',
221 222 repo_name=pull_request.target_repo.repo_name,
222 223 pull_request_id=str(pull_request_id))
223 224
224 225 response = self.app.get(pull_request_url)
225 226
226 227 assertr = AssertResponse(response)
227 228 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
228 229 MergeFailureReason.MISSING_TARGET_REF]
229 230 assertr.element_contains(
230 231 'span[data-role="merge-message"]', str(expected_msg))
231 232
232 233 def test_comment_and_close_pull_request(self, pr_util, csrf_token):
233 234 pull_request = pr_util.create_pull_request(approved=True)
234 235 pull_request_id = pull_request.pull_request_id
235 236 author = pull_request.user_id
236 237 repo = pull_request.target_repo.repo_id
237 238
238 239 self.app.post(
239 240 url(controller='pullrequests',
240 241 action='comment',
241 242 repo_name=pull_request.target_repo.scm_instance().name,
242 243 pull_request_id=str(pull_request_id)),
243 244 params={
244 245 'changeset_status':
245 246 ChangesetStatus.STATUS_APPROVED + '_closed',
246 247 'change_changeset_status': 'on',
247 248 'text': '',
248 249 'csrf_token': csrf_token},
249 250 status=302)
250 251
251 252 action = 'user_closed_pull_request:%d' % pull_request_id
252 253 journal = UserLog.query()\
253 254 .filter(UserLog.user_id == author)\
254 255 .filter(UserLog.repository_id == repo)\
255 256 .filter(UserLog.action == action)\
256 257 .all()
257 258 assert len(journal) == 1
258 259
259 260 def test_reject_and_close_pull_request(self, pr_util, csrf_token):
260 261 pull_request = pr_util.create_pull_request()
261 262 pull_request_id = pull_request.pull_request_id
262 263 response = self.app.post(
263 264 url(controller='pullrequests',
264 265 action='update',
265 266 repo_name=pull_request.target_repo.scm_instance().name,
266 267 pull_request_id=str(pull_request.pull_request_id)),
267 268 params={'close_pull_request': 'true', '_method': 'put',
268 269 'csrf_token': csrf_token})
269 270
270 271 pull_request = PullRequest.get(pull_request_id)
271 272
272 273 assert response.json is True
273 274 assert pull_request.is_closed()
274 275
275 276 # check only the latest status, not the review status
276 277 status = ChangesetStatusModel().get_status(
277 278 pull_request.source_repo, pull_request=pull_request)
278 279 assert status == ChangesetStatus.STATUS_REJECTED
279 280
280 281 def test_comment_force_close_pull_request(self, pr_util, csrf_token):
281 282 pull_request = pr_util.create_pull_request()
282 283 pull_request_id = pull_request.pull_request_id
283 284 reviewers_data = [(1, ['reason']), (2, ['reason2'])]
284 285 PullRequestModel().update_reviewers(pull_request_id, reviewers_data)
285 286 author = pull_request.user_id
286 287 repo = pull_request.target_repo.repo_id
287 288 self.app.post(
288 289 url(controller='pullrequests',
289 290 action='comment',
290 291 repo_name=pull_request.target_repo.scm_instance().name,
291 292 pull_request_id=str(pull_request_id)),
292 293 params={
293 294 'changeset_status': 'forced_closed',
294 295 'csrf_token': csrf_token},
295 296 status=302)
296 297
297 298 pull_request = PullRequest.get(pull_request_id)
298 299
299 300 action = 'user_closed_pull_request:%d' % pull_request_id
300 301 journal = UserLog.query().filter(
301 302 UserLog.user_id == author,
302 303 UserLog.repository_id == repo,
303 304 UserLog.action == action).all()
304 305 assert len(journal) == 1
305 306
306 307 # check only the latest status, not the review status
307 308 status = ChangesetStatusModel().get_status(
308 309 pull_request.source_repo, pull_request=pull_request)
309 310 assert status == ChangesetStatus.STATUS_REJECTED
310 311
311 312 def test_create_pull_request(self, backend, csrf_token):
312 313 commits = [
313 314 {'message': 'ancestor'},
314 315 {'message': 'change'},
315 316 {'message': 'change2'},
316 317 ]
317 318 commit_ids = backend.create_master_repo(commits)
318 319 target = backend.create_repo(heads=['ancestor'])
319 320 source = backend.create_repo(heads=['change2'])
320 321
321 322 response = self.app.post(
322 323 url(
323 324 controller='pullrequests',
324 325 action='create',
325 326 repo_name=source.repo_name
326 327 ),
327 328 [
328 329 ('source_repo', source.repo_name),
329 330 ('source_ref', 'branch:default:' + commit_ids['change2']),
330 331 ('target_repo', target.repo_name),
331 332 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
332 333 ('pullrequest_desc', 'Description'),
333 334 ('pullrequest_title', 'Title'),
334 335 ('__start__', 'review_members:sequence'),
335 336 ('__start__', 'reviewer:mapping'),
336 337 ('user_id', '1'),
337 338 ('__start__', 'reasons:sequence'),
338 339 ('reason', 'Some reason'),
339 340 ('__end__', 'reasons:sequence'),
340 341 ('__end__', 'reviewer:mapping'),
341 342 ('__end__', 'review_members:sequence'),
342 343 ('__start__', 'revisions:sequence'),
343 344 ('revisions', commit_ids['change']),
344 345 ('revisions', commit_ids['change2']),
345 346 ('__end__', 'revisions:sequence'),
346 347 ('user', ''),
347 348 ('csrf_token', csrf_token),
348 349 ],
349 350 status=302)
350 351
351 352 location = response.headers['Location']
352 353 pull_request_id = int(location.rsplit('/', 1)[1])
353 354 pull_request = PullRequest.get(pull_request_id)
354 355
355 356 # check that we have now both revisions
356 357 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
357 358 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
358 359 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
359 360 assert pull_request.target_ref == expected_target_ref
360 361
361 362 def test_reviewer_notifications(self, backend, csrf_token):
362 363 # We have to use the app.post for this test so it will create the
363 364 # notifications properly with the new PR
364 365 commits = [
365 366 {'message': 'ancestor',
366 367 'added': [FileNode('file_A', content='content_of_ancestor')]},
367 368 {'message': 'change',
368 369 'added': [FileNode('file_a', content='content_of_change')]},
369 370 {'message': 'change-child'},
370 371 {'message': 'ancestor-child', 'parents': ['ancestor'],
371 372 'added': [
372 373 FileNode('file_B', content='content_of_ancestor_child')]},
373 374 {'message': 'ancestor-child-2'},
374 375 ]
375 376 commit_ids = backend.create_master_repo(commits)
376 377 target = backend.create_repo(heads=['ancestor-child'])
377 378 source = backend.create_repo(heads=['change'])
378 379
379 380 response = self.app.post(
380 381 url(
381 382 controller='pullrequests',
382 383 action='create',
383 384 repo_name=source.repo_name
384 385 ),
385 386 [
386 387 ('source_repo', source.repo_name),
387 388 ('source_ref', 'branch:default:' + commit_ids['change']),
388 389 ('target_repo', target.repo_name),
389 390 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
390 391 ('pullrequest_desc', 'Description'),
391 392 ('pullrequest_title', 'Title'),
392 393 ('__start__', 'review_members:sequence'),
393 394 ('__start__', 'reviewer:mapping'),
394 395 ('user_id', '2'),
395 396 ('__start__', 'reasons:sequence'),
396 397 ('reason', 'Some reason'),
397 398 ('__end__', 'reasons:sequence'),
398 399 ('__end__', 'reviewer:mapping'),
399 400 ('__end__', 'review_members:sequence'),
400 401 ('__start__', 'revisions:sequence'),
401 402 ('revisions', commit_ids['change']),
402 403 ('__end__', 'revisions:sequence'),
403 404 ('user', ''),
404 405 ('csrf_token', csrf_token),
405 406 ],
406 407 status=302)
407 408
408 409 location = response.headers['Location']
409 410 pull_request_id = int(location.rsplit('/', 1)[1])
410 411 pull_request = PullRequest.get(pull_request_id)
411 412
412 413 # Check that a notification was made
413 414 notifications = Notification.query()\
414 415 .filter(Notification.created_by == pull_request.author.user_id,
415 416 Notification.type_ == Notification.TYPE_PULL_REQUEST,
416 417 Notification.subject.contains("wants you to review "
417 418 "pull request #%d"
418 419 % pull_request_id))
419 420 assert len(notifications.all()) == 1
420 421
421 422 # Change reviewers and check that a notification was made
422 423 PullRequestModel().update_reviewers(
423 424 pull_request.pull_request_id, [(1, [])])
424 425 assert len(notifications.all()) == 2
425 426
426 427 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
427 428 csrf_token):
428 429 commits = [
429 430 {'message': 'ancestor',
430 431 'added': [FileNode('file_A', content='content_of_ancestor')]},
431 432 {'message': 'change',
432 433 'added': [FileNode('file_a', content='content_of_change')]},
433 434 {'message': 'change-child'},
434 435 {'message': 'ancestor-child', 'parents': ['ancestor'],
435 436 'added': [
436 437 FileNode('file_B', content='content_of_ancestor_child')]},
437 438 {'message': 'ancestor-child-2'},
438 439 ]
439 440 commit_ids = backend.create_master_repo(commits)
440 441 target = backend.create_repo(heads=['ancestor-child'])
441 442 source = backend.create_repo(heads=['change'])
442 443
443 444 response = self.app.post(
444 445 url(
445 446 controller='pullrequests',
446 447 action='create',
447 448 repo_name=source.repo_name
448 449 ),
449 450 [
450 451 ('source_repo', source.repo_name),
451 452 ('source_ref', 'branch:default:' + commit_ids['change']),
452 453 ('target_repo', target.repo_name),
453 454 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
454 455 ('pullrequest_desc', 'Description'),
455 456 ('pullrequest_title', 'Title'),
456 457 ('__start__', 'review_members:sequence'),
457 458 ('__start__', 'reviewer:mapping'),
458 459 ('user_id', '1'),
459 460 ('__start__', 'reasons:sequence'),
460 461 ('reason', 'Some reason'),
461 462 ('__end__', 'reasons:sequence'),
462 463 ('__end__', 'reviewer:mapping'),
463 464 ('__end__', 'review_members:sequence'),
464 465 ('__start__', 'revisions:sequence'),
465 466 ('revisions', commit_ids['change']),
466 467 ('__end__', 'revisions:sequence'),
467 468 ('user', ''),
468 469 ('csrf_token', csrf_token),
469 470 ],
470 471 status=302)
471 472
472 473 location = response.headers['Location']
473 474 pull_request_id = int(location.rsplit('/', 1)[1])
474 475 pull_request = PullRequest.get(pull_request_id)
475 476
476 477 # target_ref has to point to the ancestor's commit_id in order to
477 478 # show the correct diff
478 479 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
479 480 assert pull_request.target_ref == expected_target_ref
480 481
481 482 # Check generated diff contents
482 483 response = response.follow()
483 484 assert 'content_of_ancestor' not in response.body
484 485 assert 'content_of_ancestor-child' not in response.body
485 486 assert 'content_of_change' in response.body
486 487
487 488 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
488 489 # Clear any previous calls to rcextensions
489 490 rhodecode.EXTENSIONS.calls.clear()
490 491
491 492 pull_request = pr_util.create_pull_request(
492 493 approved=True, mergeable=True)
493 494 pull_request_id = pull_request.pull_request_id
494 495 repo_name = pull_request.target_repo.scm_instance().name,
495 496
496 497 response = self.app.post(
497 498 url(controller='pullrequests',
498 499 action='merge',
499 500 repo_name=str(repo_name[0]),
500 501 pull_request_id=str(pull_request_id)),
501 502 params={'csrf_token': csrf_token}).follow()
502 503
503 504 pull_request = PullRequest.get(pull_request_id)
504 505
505 506 assert response.status_int == 200
506 507 assert pull_request.is_closed()
507 508 assert_pull_request_status(
508 509 pull_request, ChangesetStatus.STATUS_APPROVED)
509 510
510 511 # Check the relevant log entries were added
511 512 user_logs = UserLog.query().order_by('-user_log_id').limit(4)
512 513 actions = [log.action for log in user_logs]
513 514 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
514 515 expected_actions = [
515 516 u'user_closed_pull_request:%d' % pull_request_id,
516 517 u'user_merged_pull_request:%d' % pull_request_id,
517 518 # The action below reflect that the post push actions were executed
518 519 u'user_commented_pull_request:%d' % pull_request_id,
519 520 u'push:%s' % ','.join(pr_commit_ids),
520 521 ]
521 522 assert actions == expected_actions
522 523
523 524 # Check post_push rcextension was really executed
524 525 push_calls = rhodecode.EXTENSIONS.calls['post_push']
525 526 assert len(push_calls) == 1
526 527 unused_last_call_args, last_call_kwargs = push_calls[0]
527 528 assert last_call_kwargs['action'] == 'push'
528 529 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
529 530
530 531 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
531 532 pull_request = pr_util.create_pull_request(mergeable=False)
532 533 pull_request_id = pull_request.pull_request_id
533 534 pull_request = PullRequest.get(pull_request_id)
534 535
535 536 response = self.app.post(
536 537 url(controller='pullrequests',
537 538 action='merge',
538 539 repo_name=pull_request.target_repo.scm_instance().name,
539 540 pull_request_id=str(pull_request.pull_request_id)),
540 541 params={'csrf_token': csrf_token}).follow()
541 542
542 543 assert response.status_int == 200
543 544 assert 'Server-side pull request merging is disabled.' in response.body
544 545
545 546 @pytest.mark.skip_backends('svn')
546 547 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
547 548 pull_request = pr_util.create_pull_request(mergeable=True)
548 549 pull_request_id = pull_request.pull_request_id
549 550 repo_name = pull_request.target_repo.scm_instance().name,
550 551
551 552 response = self.app.post(
552 553 url(controller='pullrequests',
553 554 action='merge',
554 555 repo_name=str(repo_name[0]),
555 556 pull_request_id=str(pull_request_id)),
556 557 params={'csrf_token': csrf_token}).follow()
557 558
558 559 pull_request = PullRequest.get(pull_request_id)
559 560
560 561 assert response.status_int == 200
561 562 assert ' Reviewer approval is pending.' in response.body
562 563
563 564 def test_update_source_revision(self, backend, csrf_token):
564 565 commits = [
565 566 {'message': 'ancestor'},
566 567 {'message': 'change'},
567 568 {'message': 'change-2'},
568 569 ]
569 570 commit_ids = backend.create_master_repo(commits)
570 571 target = backend.create_repo(heads=['ancestor'])
571 572 source = backend.create_repo(heads=['change'])
572 573
573 574 # create pr from a in source to A in target
574 575 pull_request = PullRequest()
575 576 pull_request.source_repo = source
576 577 # TODO: johbo: Make sure that we write the source ref this way!
577 578 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
578 579 branch=backend.default_branch_name, commit_id=commit_ids['change'])
579 580 pull_request.target_repo = target
580 581
581 582 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
582 583 branch=backend.default_branch_name,
583 584 commit_id=commit_ids['ancestor'])
584 585 pull_request.revisions = [commit_ids['change']]
585 586 pull_request.title = u"Test"
586 587 pull_request.description = u"Description"
587 588 pull_request.author = UserModel().get_by_username(
588 589 TEST_USER_ADMIN_LOGIN)
589 590 Session().add(pull_request)
590 591 Session().commit()
591 592 pull_request_id = pull_request.pull_request_id
592 593
593 594 # source has ancestor - change - change-2
594 595 backend.pull_heads(source, heads=['change-2'])
595 596
596 597 # update PR
597 598 self.app.post(
598 599 url(controller='pullrequests', action='update',
599 600 repo_name=target.repo_name,
600 601 pull_request_id=str(pull_request_id)),
601 602 params={'update_commits': 'true', '_method': 'put',
602 603 'csrf_token': csrf_token})
603 604
604 605 # check that we have now both revisions
605 606 pull_request = PullRequest.get(pull_request_id)
606 607 assert pull_request.revisions == [
607 608 commit_ids['change-2'], commit_ids['change']]
608 609
609 610 # TODO: johbo: this should be a test on its own
610 611 response = self.app.get(url(
611 612 controller='pullrequests', action='index',
612 613 repo_name=target.repo_name))
613 614 assert response.status_int == 200
614 615 assert 'Pull request updated to' in response.body
615 616 assert 'with 1 added, 0 removed commits.' in response.body
616 617
617 618 def test_update_target_revision(self, backend, csrf_token):
618 619 commits = [
619 620 {'message': 'ancestor'},
620 621 {'message': 'change'},
621 622 {'message': 'ancestor-new', 'parents': ['ancestor']},
622 623 {'message': 'change-rebased'},
623 624 ]
624 625 commit_ids = backend.create_master_repo(commits)
625 626 target = backend.create_repo(heads=['ancestor'])
626 627 source = backend.create_repo(heads=['change'])
627 628
628 629 # create pr from a in source to A in target
629 630 pull_request = PullRequest()
630 631 pull_request.source_repo = source
631 632 # TODO: johbo: Make sure that we write the source ref this way!
632 633 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
633 634 branch=backend.default_branch_name, commit_id=commit_ids['change'])
634 635 pull_request.target_repo = target
635 636 # TODO: johbo: Target ref should be branch based, since tip can jump
636 637 # from branch to branch
637 638 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
638 639 branch=backend.default_branch_name,
639 640 commit_id=commit_ids['ancestor'])
640 641 pull_request.revisions = [commit_ids['change']]
641 642 pull_request.title = u"Test"
642 643 pull_request.description = u"Description"
643 644 pull_request.author = UserModel().get_by_username(
644 645 TEST_USER_ADMIN_LOGIN)
645 646 Session().add(pull_request)
646 647 Session().commit()
647 648 pull_request_id = pull_request.pull_request_id
648 649
649 650 # target has ancestor - ancestor-new
650 651 # source has ancestor - ancestor-new - change-rebased
651 652 backend.pull_heads(target, heads=['ancestor-new'])
652 653 backend.pull_heads(source, heads=['change-rebased'])
653 654
654 655 # update PR
655 656 self.app.post(
656 657 url(controller='pullrequests', action='update',
657 658 repo_name=target.repo_name,
658 659 pull_request_id=str(pull_request_id)),
659 660 params={'update_commits': 'true', '_method': 'put',
660 661 'csrf_token': csrf_token},
661 662 status=200)
662 663
663 664 # check that we have now both revisions
664 665 pull_request = PullRequest.get(pull_request_id)
665 666 assert pull_request.revisions == [commit_ids['change-rebased']]
666 667 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
667 668 branch=backend.default_branch_name,
668 669 commit_id=commit_ids['ancestor-new'])
669 670
670 671 # TODO: johbo: This should be a test on its own
671 672 response = self.app.get(url(
672 673 controller='pullrequests', action='index',
673 674 repo_name=target.repo_name))
674 675 assert response.status_int == 200
675 676 assert 'Pull request updated to' in response.body
676 677 assert 'with 1 added, 1 removed commits.' in response.body
677 678
678 679 def test_update_of_ancestor_reference(self, backend, csrf_token):
679 680 commits = [
680 681 {'message': 'ancestor'},
681 682 {'message': 'change'},
682 683 {'message': 'change-2'},
683 684 {'message': 'ancestor-new', 'parents': ['ancestor']},
684 685 {'message': 'change-rebased'},
685 686 ]
686 687 commit_ids = backend.create_master_repo(commits)
687 688 target = backend.create_repo(heads=['ancestor'])
688 689 source = backend.create_repo(heads=['change'])
689 690
690 691 # create pr from a in source to A in target
691 692 pull_request = PullRequest()
692 693 pull_request.source_repo = source
693 694 # TODO: johbo: Make sure that we write the source ref this way!
694 695 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
695 696 branch=backend.default_branch_name,
696 697 commit_id=commit_ids['change'])
697 698 pull_request.target_repo = target
698 699 # TODO: johbo: Target ref should be branch based, since tip can jump
699 700 # from branch to branch
700 701 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
701 702 branch=backend.default_branch_name,
702 703 commit_id=commit_ids['ancestor'])
703 704 pull_request.revisions = [commit_ids['change']]
704 705 pull_request.title = u"Test"
705 706 pull_request.description = u"Description"
706 707 pull_request.author = UserModel().get_by_username(
707 708 TEST_USER_ADMIN_LOGIN)
708 709 Session().add(pull_request)
709 710 Session().commit()
710 711 pull_request_id = pull_request.pull_request_id
711 712
712 713 # target has ancestor - ancestor-new
713 714 # source has ancestor - ancestor-new - change-rebased
714 715 backend.pull_heads(target, heads=['ancestor-new'])
715 716 backend.pull_heads(source, heads=['change-rebased'])
716 717
717 718 # update PR
718 719 self.app.post(
719 720 url(controller='pullrequests', action='update',
720 721 repo_name=target.repo_name,
721 722 pull_request_id=str(pull_request_id)),
722 723 params={'update_commits': 'true', '_method': 'put',
723 724 'csrf_token': csrf_token},
724 725 status=200)
725 726
726 727 # Expect the target reference to be updated correctly
727 728 pull_request = PullRequest.get(pull_request_id)
728 729 assert pull_request.revisions == [commit_ids['change-rebased']]
729 730 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
730 731 branch=backend.default_branch_name,
731 732 commit_id=commit_ids['ancestor-new'])
732 733 assert pull_request.target_ref == expected_target_ref
733 734
734 735 def test_remove_pull_request_branch(self, backend_git, csrf_token):
735 736 branch_name = 'development'
736 737 commits = [
737 738 {'message': 'initial-commit'},
738 739 {'message': 'old-feature'},
739 740 {'message': 'new-feature', 'branch': branch_name},
740 741 ]
741 742 repo = backend_git.create_repo(commits)
742 743 commit_ids = backend_git.commit_ids
743 744
744 745 pull_request = PullRequest()
745 746 pull_request.source_repo = repo
746 747 pull_request.target_repo = repo
747 748 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
748 749 branch=branch_name, commit_id=commit_ids['new-feature'])
749 750 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
750 751 branch=backend_git.default_branch_name,
751 752 commit_id=commit_ids['old-feature'])
752 753 pull_request.revisions = [commit_ids['new-feature']]
753 754 pull_request.title = u"Test"
754 755 pull_request.description = u"Description"
755 756 pull_request.author = UserModel().get_by_username(
756 757 TEST_USER_ADMIN_LOGIN)
757 758 Session().add(pull_request)
758 759 Session().commit()
759 760
760 761 vcs = repo.scm_instance()
761 762 vcs.remove_ref('refs/heads/{}'.format(branch_name))
762 763
763 764 response = self.app.get(url(
764 765 controller='pullrequests', action='show',
765 766 repo_name=repo.repo_name,
766 767 pull_request_id=str(pull_request.pull_request_id)))
767 768
768 769 assert response.status_int == 200
769 770 assert_response = AssertResponse(response)
770 771 assert_response.element_contains(
771 772 '#changeset_compare_view_content .alert strong',
772 773 'Missing commits')
773 774 assert_response.element_contains(
774 775 '#changeset_compare_view_content .alert',
775 776 'This pull request cannot be displayed, because one or more'
776 777 ' commits no longer exist in the source repository.')
777 778
778 779 def test_strip_commits_from_pull_request(
779 780 self, backend, pr_util, csrf_token):
780 781 commits = [
781 782 {'message': 'initial-commit'},
782 783 {'message': 'old-feature'},
783 784 {'message': 'new-feature', 'parents': ['initial-commit']},
784 785 ]
785 786 pull_request = pr_util.create_pull_request(
786 787 commits, target_head='initial-commit', source_head='new-feature',
787 788 revisions=['new-feature'])
788 789
789 790 vcs = pr_util.source_repository.scm_instance()
790 791 if backend.alias == 'git':
791 792 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
792 793 else:
793 794 vcs.strip(pr_util.commit_ids['new-feature'])
794 795
795 796 response = self.app.get(url(
796 797 controller='pullrequests', action='show',
797 798 repo_name=pr_util.target_repository.repo_name,
798 799 pull_request_id=str(pull_request.pull_request_id)))
799 800
800 801 assert response.status_int == 200
801 802 assert_response = AssertResponse(response)
802 803 assert_response.element_contains(
803 804 '#changeset_compare_view_content .alert strong',
804 805 'Missing commits')
805 806 assert_response.element_contains(
806 807 '#changeset_compare_view_content .alert',
807 808 'This pull request cannot be displayed, because one or more'
808 809 ' commits no longer exist in the source repository.')
809 810 assert_response.element_contains(
810 811 '#update_commits',
811 812 'Update commits')
812 813
813 814 def test_strip_commits_and_update(
814 815 self, backend, pr_util, csrf_token):
815 816 commits = [
816 817 {'message': 'initial-commit'},
817 818 {'message': 'old-feature'},
818 819 {'message': 'new-feature', 'parents': ['old-feature']},
819 820 ]
820 821 pull_request = pr_util.create_pull_request(
821 822 commits, target_head='old-feature', source_head='new-feature',
822 823 revisions=['new-feature'], mergeable=True)
823 824
824 825 vcs = pr_util.source_repository.scm_instance()
825 826 if backend.alias == 'git':
826 827 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
827 828 else:
828 829 vcs.strip(pr_util.commit_ids['new-feature'])
829 830
830 831 response = self.app.post(
831 832 url(controller='pullrequests', action='update',
832 833 repo_name=pull_request.target_repo.repo_name,
833 834 pull_request_id=str(pull_request.pull_request_id)),
834 835 params={'update_commits': 'true', '_method': 'put',
835 836 'csrf_token': csrf_token})
836 837
837 838 assert response.status_int == 200
838 839 assert response.body == 'true'
839 840
840 841 # Make sure that after update, it won't raise 500 errors
841 842 response = self.app.get(url(
842 843 controller='pullrequests', action='show',
843 844 repo_name=pr_util.target_repository.repo_name,
844 845 pull_request_id=str(pull_request.pull_request_id)))
845 846
846 847 assert response.status_int == 200
847 848 assert_response = AssertResponse(response)
848 849 assert_response.element_contains(
849 850 '#changeset_compare_view_content .alert strong',
850 851 'Missing commits')
851 852
852 853 def test_branch_is_a_link(self, pr_util):
853 854 pull_request = pr_util.create_pull_request()
854 855 pull_request.source_ref = 'branch:origin:1234567890abcdef'
855 856 pull_request.target_ref = 'branch:target:abcdef1234567890'
856 857 Session().add(pull_request)
857 858 Session().commit()
858 859
859 860 response = self.app.get(url(
860 861 controller='pullrequests', action='show',
861 862 repo_name=pull_request.target_repo.scm_instance().name,
862 863 pull_request_id=str(pull_request.pull_request_id)))
863 864 assert response.status_int == 200
864 865 assert_response = AssertResponse(response)
865 866
866 867 origin = assert_response.get_element('.pr-origininfo .tag')
867 868 origin_children = origin.getchildren()
868 869 assert len(origin_children) == 1
869 870 target = assert_response.get_element('.pr-targetinfo .tag')
870 871 target_children = target.getchildren()
871 872 assert len(target_children) == 1
872 873
873 874 expected_origin_link = url(
874 875 'changelog_home',
875 876 repo_name=pull_request.source_repo.scm_instance().name,
876 877 branch='origin')
877 878 expected_target_link = url(
878 879 'changelog_home',
879 880 repo_name=pull_request.target_repo.scm_instance().name,
880 881 branch='target')
881 882 assert origin_children[0].attrib['href'] == expected_origin_link
882 883 assert origin_children[0].text == 'branch: origin'
883 884 assert target_children[0].attrib['href'] == expected_target_link
884 885 assert target_children[0].text == 'branch: target'
885 886
886 887 def test_bookmark_is_not_a_link(self, pr_util):
887 888 pull_request = pr_util.create_pull_request()
888 889 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
889 890 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
890 891 Session().add(pull_request)
891 892 Session().commit()
892 893
893 894 response = self.app.get(url(
894 895 controller='pullrequests', action='show',
895 896 repo_name=pull_request.target_repo.scm_instance().name,
896 897 pull_request_id=str(pull_request.pull_request_id)))
897 898 assert response.status_int == 200
898 899 assert_response = AssertResponse(response)
899 900
900 901 origin = assert_response.get_element('.pr-origininfo .tag')
901 902 assert origin.text.strip() == 'bookmark: origin'
902 903 assert origin.getchildren() == []
903 904
904 905 target = assert_response.get_element('.pr-targetinfo .tag')
905 906 assert target.text.strip() == 'bookmark: target'
906 907 assert target.getchildren() == []
907 908
908 909 def test_tag_is_not_a_link(self, pr_util):
909 910 pull_request = pr_util.create_pull_request()
910 911 pull_request.source_ref = 'tag:origin:1234567890abcdef'
911 912 pull_request.target_ref = 'tag:target:abcdef1234567890'
912 913 Session().add(pull_request)
913 914 Session().commit()
914 915
915 916 response = self.app.get(url(
916 917 controller='pullrequests', action='show',
917 918 repo_name=pull_request.target_repo.scm_instance().name,
918 919 pull_request_id=str(pull_request.pull_request_id)))
919 920 assert response.status_int == 200
920 921 assert_response = AssertResponse(response)
921 922
922 923 origin = assert_response.get_element('.pr-origininfo .tag')
923 924 assert origin.text.strip() == 'tag: origin'
924 925 assert origin.getchildren() == []
925 926
926 927 target = assert_response.get_element('.pr-targetinfo .tag')
927 928 assert target.text.strip() == 'tag: target'
928 929 assert target.getchildren() == []
929 930
930 931 def test_description_is_escaped_on_index_page(self, backend, pr_util):
931 932 xss_description = "<script>alert('Hi!')</script>"
932 933 pull_request = pr_util.create_pull_request(description=xss_description)
933 934 response = self.app.get(url(
934 935 controller='pullrequests', action='show_all',
935 936 repo_name=pull_request.target_repo.repo_name))
936 937 response.mustcontain(
937 938 "&lt;script&gt;alert(&#39;Hi!&#39;)&lt;/script&gt;")
938 939
939 940 @pytest.mark.parametrize('mergeable', [True, False])
940 941 def test_shadow_repository_link(
941 942 self, mergeable, pr_util, http_host_stub):
942 943 """
943 944 Check that the pull request summary page displays a link to the shadow
944 945 repository if the pull request is mergeable. If it is not mergeable
945 946 the link should not be displayed.
946 947 """
947 948 pull_request = pr_util.create_pull_request(
948 949 mergeable=mergeable, enable_notifications=False)
949 950 target_repo = pull_request.target_repo.scm_instance()
950 951 pr_id = pull_request.pull_request_id
951 952 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
952 953 host=http_host_stub, repo=target_repo.name, pr_id=pr_id)
953 954
954 955 response = self.app.get(url(
955 956 controller='pullrequests', action='show',
956 957 repo_name=target_repo.name,
957 958 pull_request_id=str(pr_id)))
958 959
959 960 assertr = AssertResponse(response)
960 961 if mergeable:
961 962 assertr.element_value_contains(
962 963 'div.pr-mergeinfo input', shadow_url)
963 964 assertr.element_value_contains(
964 965 'div.pr-mergeinfo input', 'pr-merge')
965 966 else:
966 967 assertr.no_element_exists('div.pr-mergeinfo')
967 968
968 969
970 @pytest.mark.usefixtures('app')
971 @pytest.mark.backends("git", "hg")
972 class TestPullrequestsControllerDelete(object):
973 def test_pull_request_delete_button_permissions_admin(
974 self, autologin_user, user_admin, pr_util):
975 pull_request = pr_util.create_pull_request(
976 author=user_admin.username, enable_notifications=False)
977
978 response = self.app.get(url(
979 controller='pullrequests', action='show',
980 repo_name=pull_request.target_repo.scm_instance().name,
981 pull_request_id=str(pull_request.pull_request_id)))
982
983 response.mustcontain('id="delete_pullrequest"')
984 response.mustcontain('Confirm to delete this pull request')
985
986 def test_pull_request_delete_button_permissions_owner(
987 self, autologin_regular_user, user_regular, pr_util):
988 pull_request = pr_util.create_pull_request(
989 author=user_regular.username, enable_notifications=False)
990
991 response = self.app.get(url(
992 controller='pullrequests', action='show',
993 repo_name=pull_request.target_repo.scm_instance().name,
994 pull_request_id=str(pull_request.pull_request_id)))
995
996 response.mustcontain('id="delete_pullrequest"')
997 response.mustcontain('Confirm to delete this pull request')
998
999 def test_pull_request_delete_button_permissions_forbidden(
1000 self, autologin_regular_user, user_regular, user_admin, pr_util):
1001 pull_request = pr_util.create_pull_request(
1002 author=user_admin.username, enable_notifications=False)
1003
1004 response = self.app.get(url(
1005 controller='pullrequests', action='show',
1006 repo_name=pull_request.target_repo.scm_instance().name,
1007 pull_request_id=str(pull_request.pull_request_id)))
1008 response.mustcontain(no=['id="delete_pullrequest"'])
1009 response.mustcontain(no=['Confirm to delete this pull request'])
1010
1011 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1012 self, autologin_regular_user, user_regular, user_admin, pr_util,
1013 user_util):
1014
1015 pull_request = pr_util.create_pull_request(
1016 author=user_admin.username, enable_notifications=False)
1017
1018 user_util.grant_user_permission_to_repo(
1019 pull_request.target_repo, user_regular,
1020 'repository.write')
1021
1022 response = self.app.get(url(
1023 controller='pullrequests', action='show',
1024 repo_name=pull_request.target_repo.scm_instance().name,
1025 pull_request_id=str(pull_request.pull_request_id)))
1026
1027 response.mustcontain('id="open_edit_pullrequest"')
1028 response.mustcontain('id="delete_pullrequest"')
1029 response.mustcontain(no=['Confirm to delete this pull request'])
1030
1031
969 1032 def assert_pull_request_status(pull_request, expected_status):
970 1033 status = ChangesetStatusModel().calculated_review_status(
971 1034 pull_request=pull_request)
972 1035 assert status == expected_status
973 1036
974 1037
975 1038 @pytest.mark.parametrize('action', ['show_all', 'index', 'create'])
976 1039 @pytest.mark.usefixtures("autologin_user")
977 1040 def test_redirects_to_repo_summary_for_svn_repositories(
978 1041 backend_svn, app, action):
979 1042 denied_actions = ['show_all', 'index', 'create']
980 1043 for action in denied_actions:
981 1044 response = app.get(url(
982 1045 controller='pullrequests', action=action,
983 1046 repo_name=backend_svn.repo_name))
984 1047 assert response.status_int == 302
985 1048
986 1049 # Not allowed, redirect to the summary
987 1050 redirected = response.follow()
988 1051 summary_url = url('summary_home', repo_name=backend_svn.repo_name)
989 1052
990 1053 # URL adds leading slash and path doesn't have it
991 1054 assert redirected.req.path == summary_url
992 1055
993 1056
994 1057 def test_delete_comment_returns_404_if_comment_does_not_exist(pylonsapp):
995 1058 # TODO: johbo: Global import not possible because models.forms blows up
996 1059 from rhodecode.controllers.pullrequests import PullrequestsController
997 1060 controller = PullrequestsController()
998 1061 patcher = mock.patch(
999 1062 'rhodecode.model.db.BaseModel.get', return_value=None)
1000 1063 with pytest.raises(HTTPNotFound), patcher:
1001 1064 controller._delete_comment(1)
General Comments 0
You need to be logged in to leave comments. Login now