##// END OF EJS Templates
diffs: fixed other file source when using pull requests. It must use...
marcink -
r1194:f606ad60 default
parent child Browse files
Show More
@@ -1,993 +1,983 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24
25 25 import peppercorn
26 26 import formencode
27 27 import logging
28 28
29 29 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
30 30 from pylons import request, tmpl_context as c, url
31 31 from pylons.controllers.util import redirect
32 32 from pylons.i18n.translation import _
33 33 from pyramid.threadlocal import get_current_registry
34 34 from sqlalchemy.sql import func
35 35 from sqlalchemy.sql.expression import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
39 39 from rhodecode.lib.ext_json import json
40 40 from rhodecode.lib.base import (
41 41 BaseRepoController, render, vcs_operation_context)
42 42 from rhodecode.lib.auth import (
43 43 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
44 44 HasAcceptedRepoType, XHRRequired)
45 45 from rhodecode.lib.channelstream import channelstream_request
46 46 from rhodecode.lib.compat import OrderedDict
47 47 from rhodecode.lib.utils import jsonify
48 48 from rhodecode.lib.utils2 import (
49 safe_int, safe_str, str2bool, safe_unicode, UnsafeAttributeDict)
49 safe_int, safe_str, str2bool, safe_unicode, StrictAttributeDict)
50 50 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
51 51 from rhodecode.lib.vcs.exceptions import (
52 52 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
53 53 NodeDoesNotExistError)
54 54
55 55 from rhodecode.model.changeset_status import ChangesetStatusModel
56 56 from rhodecode.model.comment import ChangesetCommentsModel
57 57 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
58 58 Repository, PullRequestVersion)
59 59 from rhodecode.model.forms import PullRequestForm
60 60 from rhodecode.model.meta import Session
61 61 from rhodecode.model.pull_request import PullRequestModel
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 class PullrequestsController(BaseRepoController):
67 67 def __before__(self):
68 68 super(PullrequestsController, self).__before__()
69 69
70 70 def _load_compare_data(self, pull_request, inline_comments, enable_comments=True):
71 71 """
72 72 Load context data needed for generating compare diff
73 73
74 74 :param pull_request: object related to the request
75 75 :param enable_comments: flag to determine if comments are included
76 76 """
77 77 source_repo = pull_request.source_repo
78 78 source_ref_id = pull_request.source_ref_parts.commit_id
79 79
80 80 target_repo = pull_request.target_repo
81 81 target_ref_id = pull_request.target_ref_parts.commit_id
82 82
83 83 # despite opening commits for bookmarks/branches/tags, we always
84 84 # convert this to rev to prevent changes after bookmark or branch change
85 85 c.source_ref_type = 'rev'
86 86 c.source_ref = source_ref_id
87 87
88 88 c.target_ref_type = 'rev'
89 89 c.target_ref = target_ref_id
90 90
91 91 c.source_repo = source_repo
92 92 c.target_repo = target_repo
93 93
94 94 c.fulldiff = bool(request.GET.get('fulldiff'))
95 95
96 96 # diff_limit is the old behavior, will cut off the whole diff
97 97 # if the limit is applied otherwise will just hide the
98 98 # big files from the front-end
99 99 diff_limit = self.cut_off_limit_diff
100 100 file_limit = self.cut_off_limit_file
101 101
102 102 pre_load = ["author", "branch", "date", "message"]
103 103
104 104 c.commit_ranges = []
105 105 source_commit = EmptyCommit()
106 106 target_commit = EmptyCommit()
107 107 c.missing_requirements = False
108 108 try:
109 109 c.commit_ranges = [
110 110 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
111 111 for rev in pull_request.revisions]
112 112
113 113 c.statuses = source_repo.statuses(
114 114 [x.raw_id for x in c.commit_ranges])
115 115
116 116 target_commit = source_repo.get_commit(
117 117 commit_id=safe_str(target_ref_id))
118 118 source_commit = source_repo.get_commit(
119 119 commit_id=safe_str(source_ref_id))
120 120 except RepositoryRequirementError:
121 121 c.missing_requirements = True
122 122
123 123 c.changes = {}
124 124 c.missing_commits = False
125 125 if (c.missing_requirements or
126 126 isinstance(source_commit, EmptyCommit) or
127 127 source_commit == target_commit):
128 128 _parsed = []
129 129 c.missing_commits = True
130 130 else:
131 131 vcs_diff = PullRequestModel().get_diff(pull_request)
132 132 diff_processor = diffs.DiffProcessor(
133 133 vcs_diff, format='newdiff', diff_limit=diff_limit,
134 134 file_limit=file_limit, show_full_diff=c.fulldiff)
135 135 _parsed = diff_processor.prepare()
136 136
137 137 commit_changes = OrderedDict()
138 138 _parsed = diff_processor.prepare()
139 139 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
140 140
141 141 _parsed = diff_processor.prepare()
142 142
143 143 def _node_getter(commit):
144 144 def get_node(fname):
145 145 try:
146 146 return commit.get_node(fname)
147 147 except NodeDoesNotExistError:
148 148 return None
149 149 return get_node
150 150
151 151 c.diffset = codeblocks.DiffSet(
152 152 repo_name=c.repo_name,
153 source_repo_name=c.source_repo.repo_name,
153 154 source_node_getter=_node_getter(target_commit),
154 155 target_node_getter=_node_getter(source_commit),
155 156 comments=inline_comments
156 157 ).render_patchset(_parsed, target_commit.raw_id, source_commit.raw_id)
157 158
158 159 c.included_files = []
159 160 c.deleted_files = []
160 161
161 162 for f in _parsed:
162 163 st = f['stats']
163 164 fid = h.FID('', f['filename'])
164 165 c.included_files.append(f['filename'])
165 166
166 167 def _extract_ordering(self, request):
167 168 column_index = safe_int(request.GET.get('order[0][column]'))
168 169 order_dir = request.GET.get('order[0][dir]', 'desc')
169 170 order_by = request.GET.get(
170 171 'columns[%s][data][sort]' % column_index, 'name_raw')
171 172 return order_by, order_dir
172 173
173 174 @LoginRequired()
174 175 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
175 176 'repository.admin')
176 177 @HasAcceptedRepoType('git', 'hg')
177 178 def show_all(self, repo_name):
178 179 # filter types
179 180 c.active = 'open'
180 181 c.source = str2bool(request.GET.get('source'))
181 182 c.closed = str2bool(request.GET.get('closed'))
182 183 c.my = str2bool(request.GET.get('my'))
183 184 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
184 185 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
185 186 c.repo_name = repo_name
186 187
187 188 opened_by = None
188 189 if c.my:
189 190 c.active = 'my'
190 191 opened_by = [c.rhodecode_user.user_id]
191 192
192 193 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
193 194 if c.closed:
194 195 c.active = 'closed'
195 196 statuses = [PullRequest.STATUS_CLOSED]
196 197
197 198 if c.awaiting_review and not c.source:
198 199 c.active = 'awaiting'
199 200 if c.source and not c.awaiting_review:
200 201 c.active = 'source'
201 202 if c.awaiting_my_review:
202 203 c.active = 'awaiting_my'
203 204
204 205 data = self._get_pull_requests_list(
205 206 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
206 207 if not request.is_xhr:
207 208 c.data = json.dumps(data['data'])
208 209 c.records_total = data['recordsTotal']
209 210 return render('/pullrequests/pullrequests.html')
210 211 else:
211 212 return json.dumps(data)
212 213
213 214 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
214 215 # pagination
215 216 start = safe_int(request.GET.get('start'), 0)
216 217 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
217 218 order_by, order_dir = self._extract_ordering(request)
218 219
219 220 if c.awaiting_review:
220 221 pull_requests = PullRequestModel().get_awaiting_review(
221 222 repo_name, source=c.source, opened_by=opened_by,
222 223 statuses=statuses, offset=start, length=length,
223 224 order_by=order_by, order_dir=order_dir)
224 225 pull_requests_total_count = PullRequestModel(
225 226 ).count_awaiting_review(
226 227 repo_name, source=c.source, statuses=statuses,
227 228 opened_by=opened_by)
228 229 elif c.awaiting_my_review:
229 230 pull_requests = PullRequestModel().get_awaiting_my_review(
230 231 repo_name, source=c.source, opened_by=opened_by,
231 232 user_id=c.rhodecode_user.user_id, statuses=statuses,
232 233 offset=start, length=length, order_by=order_by,
233 234 order_dir=order_dir)
234 235 pull_requests_total_count = PullRequestModel(
235 236 ).count_awaiting_my_review(
236 237 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
237 238 statuses=statuses, opened_by=opened_by)
238 239 else:
239 240 pull_requests = PullRequestModel().get_all(
240 241 repo_name, source=c.source, opened_by=opened_by,
241 242 statuses=statuses, offset=start, length=length,
242 243 order_by=order_by, order_dir=order_dir)
243 244 pull_requests_total_count = PullRequestModel().count_all(
244 245 repo_name, source=c.source, statuses=statuses,
245 246 opened_by=opened_by)
246 247
247 248 from rhodecode.lib.utils import PartialRenderer
248 249 _render = PartialRenderer('data_table/_dt_elements.html')
249 250 data = []
250 251 for pr in pull_requests:
251 252 comments = ChangesetCommentsModel().get_all_comments(
252 253 c.rhodecode_db_repo.repo_id, pull_request=pr)
253 254
254 255 data.append({
255 256 'name': _render('pullrequest_name',
256 257 pr.pull_request_id, pr.target_repo.repo_name),
257 258 'name_raw': pr.pull_request_id,
258 259 'status': _render('pullrequest_status',
259 260 pr.calculated_review_status()),
260 261 'title': _render(
261 262 'pullrequest_title', pr.title, pr.description),
262 263 'description': h.escape(pr.description),
263 264 'updated_on': _render('pullrequest_updated_on',
264 265 h.datetime_to_time(pr.updated_on)),
265 266 'updated_on_raw': h.datetime_to_time(pr.updated_on),
266 267 'created_on': _render('pullrequest_updated_on',
267 268 h.datetime_to_time(pr.created_on)),
268 269 'created_on_raw': h.datetime_to_time(pr.created_on),
269 270 'author': _render('pullrequest_author',
270 271 pr.author.full_contact, ),
271 272 'author_raw': pr.author.full_name,
272 273 'comments': _render('pullrequest_comments', len(comments)),
273 274 'comments_raw': len(comments),
274 275 'closed': pr.is_closed(),
275 276 })
276 277 # json used to render the grid
277 278 data = ({
278 279 'data': data,
279 280 'recordsTotal': pull_requests_total_count,
280 281 'recordsFiltered': pull_requests_total_count,
281 282 })
282 283 return data
283 284
284 285 @LoginRequired()
285 286 @NotAnonymous()
286 287 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
287 288 'repository.admin')
288 289 @HasAcceptedRepoType('git', 'hg')
289 290 def index(self):
290 291 source_repo = c.rhodecode_db_repo
291 292
292 293 try:
293 294 source_repo.scm_instance().get_commit()
294 295 except EmptyRepositoryError:
295 296 h.flash(h.literal(_('There are no commits yet')),
296 297 category='warning')
297 298 redirect(url('summary_home', repo_name=source_repo.repo_name))
298 299
299 300 commit_id = request.GET.get('commit')
300 301 branch_ref = request.GET.get('branch')
301 302 bookmark_ref = request.GET.get('bookmark')
302 303
303 304 try:
304 305 source_repo_data = PullRequestModel().generate_repo_data(
305 306 source_repo, commit_id=commit_id,
306 307 branch=branch_ref, bookmark=bookmark_ref)
307 308 except CommitDoesNotExistError as e:
308 309 log.exception(e)
309 310 h.flash(_('Commit does not exist'), 'error')
310 311 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
311 312
312 313 default_target_repo = source_repo
313 314
314 315 if source_repo.parent:
315 316 parent_vcs_obj = source_repo.parent.scm_instance()
316 317 if parent_vcs_obj and not parent_vcs_obj.is_empty():
317 318 # change default if we have a parent repo
318 319 default_target_repo = source_repo.parent
319 320
320 321 target_repo_data = PullRequestModel().generate_repo_data(
321 322 default_target_repo)
322 323
323 324 selected_source_ref = source_repo_data['refs']['selected_ref']
324 325
325 326 title_source_ref = selected_source_ref.split(':', 2)[1]
326 327 c.default_title = PullRequestModel().generate_pullrequest_title(
327 328 source=source_repo.repo_name,
328 329 source_ref=title_source_ref,
329 330 target=default_target_repo.repo_name
330 331 )
331 332
332 333 c.default_repo_data = {
333 334 'source_repo_name': source_repo.repo_name,
334 335 'source_refs_json': json.dumps(source_repo_data),
335 336 'target_repo_name': default_target_repo.repo_name,
336 337 'target_refs_json': json.dumps(target_repo_data),
337 338 }
338 339 c.default_source_ref = selected_source_ref
339 340
340 341 return render('/pullrequests/pullrequest.html')
341 342
342 343 @LoginRequired()
343 344 @NotAnonymous()
344 345 @XHRRequired()
345 346 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
346 347 'repository.admin')
347 348 @jsonify
348 349 def get_repo_refs(self, repo_name, target_repo_name):
349 350 repo = Repository.get_by_repo_name(target_repo_name)
350 351 if not repo:
351 352 raise HTTPNotFound
352 353 return PullRequestModel().generate_repo_data(repo)
353 354
354 355 @LoginRequired()
355 356 @NotAnonymous()
356 357 @XHRRequired()
357 358 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
358 359 'repository.admin')
359 360 @jsonify
360 361 def get_repo_destinations(self, repo_name):
361 362 repo = Repository.get_by_repo_name(repo_name)
362 363 if not repo:
363 364 raise HTTPNotFound
364 365 filter_query = request.GET.get('query')
365 366
366 367 query = Repository.query() \
367 368 .order_by(func.length(Repository.repo_name)) \
368 369 .filter(or_(
369 370 Repository.repo_name == repo.repo_name,
370 371 Repository.fork_id == repo.repo_id))
371 372
372 373 if filter_query:
373 374 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
374 375 query = query.filter(
375 376 Repository.repo_name.ilike(ilike_expression))
376 377
377 378 add_parent = False
378 379 if repo.parent:
379 380 if filter_query in repo.parent.repo_name:
380 381 parent_vcs_obj = repo.parent.scm_instance()
381 382 if parent_vcs_obj and not parent_vcs_obj.is_empty():
382 383 add_parent = True
383 384
384 385 limit = 20 - 1 if add_parent else 20
385 386 all_repos = query.limit(limit).all()
386 387 if add_parent:
387 388 all_repos += [repo.parent]
388 389
389 390 repos = []
390 391 for obj in self.scm_model.get_repos(all_repos):
391 392 repos.append({
392 393 'id': obj['name'],
393 394 'text': obj['name'],
394 395 'type': 'repo',
395 396 'obj': obj['dbrepo']
396 397 })
397 398
398 399 data = {
399 400 'more': False,
400 401 'results': [{
401 402 'text': _('Repositories'),
402 403 'children': repos
403 404 }] if repos else []
404 405 }
405 406 return data
406 407
407 408 @LoginRequired()
408 409 @NotAnonymous()
409 410 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
410 411 'repository.admin')
411 412 @HasAcceptedRepoType('git', 'hg')
412 413 @auth.CSRFRequired()
413 414 def create(self, repo_name):
414 415 repo = Repository.get_by_repo_name(repo_name)
415 416 if not repo:
416 417 raise HTTPNotFound
417 418
418 419 controls = peppercorn.parse(request.POST.items())
419 420
420 421 try:
421 422 _form = PullRequestForm(repo.repo_id)().to_python(controls)
422 423 except formencode.Invalid as errors:
423 424 if errors.error_dict.get('revisions'):
424 425 msg = 'Revisions: %s' % errors.error_dict['revisions']
425 426 elif errors.error_dict.get('pullrequest_title'):
426 427 msg = _('Pull request requires a title with min. 3 chars')
427 428 else:
428 429 msg = _('Error creating pull request: {}').format(errors)
429 430 log.exception(msg)
430 431 h.flash(msg, 'error')
431 432
432 433 # would rather just go back to form ...
433 434 return redirect(url('pullrequest_home', repo_name=repo_name))
434 435
435 436 source_repo = _form['source_repo']
436 437 source_ref = _form['source_ref']
437 438 target_repo = _form['target_repo']
438 439 target_ref = _form['target_ref']
439 440 commit_ids = _form['revisions'][::-1]
440 441 reviewers = [
441 442 (r['user_id'], r['reasons']) for r in _form['review_members']]
442 443
443 444 # find the ancestor for this pr
444 445 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
445 446 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
446 447
447 448 source_scm = source_db_repo.scm_instance()
448 449 target_scm = target_db_repo.scm_instance()
449 450
450 451 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
451 452 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
452 453
453 454 ancestor = source_scm.get_common_ancestor(
454 455 source_commit.raw_id, target_commit.raw_id, target_scm)
455 456
456 457 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
457 458 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
458 459
459 460 pullrequest_title = _form['pullrequest_title']
460 461 title_source_ref = source_ref.split(':', 2)[1]
461 462 if not pullrequest_title:
462 463 pullrequest_title = PullRequestModel().generate_pullrequest_title(
463 464 source=source_repo,
464 465 source_ref=title_source_ref,
465 466 target=target_repo
466 467 )
467 468
468 469 description = _form['pullrequest_desc']
469 470 try:
470 471 pull_request = PullRequestModel().create(
471 472 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
472 473 target_ref, commit_ids, reviewers, pullrequest_title,
473 474 description
474 475 )
475 476 Session().commit()
476 477 h.flash(_('Successfully opened new pull request'),
477 478 category='success')
478 479 except Exception as e:
479 480 msg = _('Error occurred during sending pull request')
480 481 log.exception(msg)
481 482 h.flash(msg, category='error')
482 483 return redirect(url('pullrequest_home', repo_name=repo_name))
483 484
484 485 return redirect(url('pullrequest_show', repo_name=target_repo,
485 486 pull_request_id=pull_request.pull_request_id))
486 487
487 488 @LoginRequired()
488 489 @NotAnonymous()
489 490 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
490 491 'repository.admin')
491 492 @auth.CSRFRequired()
492 493 @jsonify
493 494 def update(self, repo_name, pull_request_id):
494 495 pull_request_id = safe_int(pull_request_id)
495 496 pull_request = PullRequest.get_or_404(pull_request_id)
496 497 # only owner or admin can update it
497 498 allowed_to_update = PullRequestModel().check_user_update(
498 499 pull_request, c.rhodecode_user)
499 500 if allowed_to_update:
500 501 controls = peppercorn.parse(request.POST.items())
501 502
502 503 if 'review_members' in controls:
503 504 self._update_reviewers(
504 505 pull_request_id, controls['review_members'])
505 506 elif str2bool(request.POST.get('update_commits', 'false')):
506 507 self._update_commits(pull_request)
507 508 elif str2bool(request.POST.get('close_pull_request', 'false')):
508 509 self._reject_close(pull_request)
509 510 elif str2bool(request.POST.get('edit_pull_request', 'false')):
510 511 self._edit_pull_request(pull_request)
511 512 else:
512 513 raise HTTPBadRequest()
513 514 return True
514 515 raise HTTPForbidden()
515 516
516 517 def _edit_pull_request(self, pull_request):
517 518 try:
518 519 PullRequestModel().edit(
519 520 pull_request, request.POST.get('title'),
520 521 request.POST.get('description'))
521 522 except ValueError:
522 523 msg = _(u'Cannot update closed pull requests.')
523 524 h.flash(msg, category='error')
524 525 return
525 526 else:
526 527 Session().commit()
527 528
528 529 msg = _(u'Pull request title & description updated.')
529 530 h.flash(msg, category='success')
530 531 return
531 532
532 533 def _update_commits(self, pull_request):
533 534 resp = PullRequestModel().update_commits(pull_request)
534 535
535 536 if resp.executed:
536 537 msg = _(
537 538 u'Pull request updated to "{source_commit_id}" with '
538 539 u'{count_added} added, {count_removed} removed commits.')
539 540 msg = msg.format(
540 541 source_commit_id=pull_request.source_ref_parts.commit_id,
541 542 count_added=len(resp.changes.added),
542 543 count_removed=len(resp.changes.removed))
543 544 h.flash(msg, category='success')
544 545
545 546 registry = get_current_registry()
546 547 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
547 548 channelstream_config = rhodecode_plugins.get('channelstream', {})
548 549 if channelstream_config.get('enabled'):
549 550 message = msg + (
550 551 ' - <a onclick="window.location.reload()">'
551 552 '<strong>{}</strong></a>'.format(_('Reload page')))
552 553 channel = '/repo${}$/pr/{}'.format(
553 554 pull_request.target_repo.repo_name,
554 555 pull_request.pull_request_id
555 556 )
556 557 payload = {
557 558 'type': 'message',
558 559 'user': 'system',
559 560 'exclude_users': [request.user.username],
560 561 'channel': channel,
561 562 'message': {
562 563 'message': message,
563 564 'level': 'success',
564 565 'topic': '/notifications'
565 566 }
566 567 }
567 568 channelstream_request(
568 569 channelstream_config, [payload], '/message',
569 570 raise_exc=False)
570 571 else:
571 572 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
572 573 warning_reasons = [
573 574 UpdateFailureReason.NO_CHANGE,
574 575 UpdateFailureReason.WRONG_REF_TPYE,
575 576 ]
576 577 category = 'warning' if resp.reason in warning_reasons else 'error'
577 578 h.flash(msg, category=category)
578 579
579 580 @auth.CSRFRequired()
580 581 @LoginRequired()
581 582 @NotAnonymous()
582 583 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
583 584 'repository.admin')
584 585 def merge(self, repo_name, pull_request_id):
585 586 """
586 587 POST /{repo_name}/pull-request/{pull_request_id}
587 588
588 589 Merge will perform a server-side merge of the specified
589 590 pull request, if the pull request is approved and mergeable.
590 591 After succesfull merging, the pull request is automatically
591 592 closed, with a relevant comment.
592 593 """
593 594 pull_request_id = safe_int(pull_request_id)
594 595 pull_request = PullRequest.get_or_404(pull_request_id)
595 596 user = c.rhodecode_user
596 597
597 598 if self._meets_merge_pre_conditions(pull_request, user):
598 599 log.debug("Pre-conditions checked, trying to merge.")
599 600 extras = vcs_operation_context(
600 601 request.environ, repo_name=pull_request.target_repo.repo_name,
601 602 username=user.username, action='push',
602 603 scm=pull_request.target_repo.repo_type)
603 604 self._merge_pull_request(pull_request, user, extras)
604 605
605 606 return redirect(url(
606 607 'pullrequest_show',
607 608 repo_name=pull_request.target_repo.repo_name,
608 609 pull_request_id=pull_request.pull_request_id))
609 610
610 611 def _meets_merge_pre_conditions(self, pull_request, user):
611 612 if not PullRequestModel().check_user_merge(pull_request, user):
612 613 raise HTTPForbidden()
613 614
614 615 merge_status, msg = PullRequestModel().merge_status(pull_request)
615 616 if not merge_status:
616 617 log.debug("Cannot merge, not mergeable.")
617 618 h.flash(msg, category='error')
618 619 return False
619 620
620 621 if (pull_request.calculated_review_status()
621 622 is not ChangesetStatus.STATUS_APPROVED):
622 623 log.debug("Cannot merge, approval is pending.")
623 624 msg = _('Pull request reviewer approval is pending.')
624 625 h.flash(msg, category='error')
625 626 return False
626 627 return True
627 628
628 629 def _merge_pull_request(self, pull_request, user, extras):
629 630 merge_resp = PullRequestModel().merge(
630 631 pull_request, user, extras=extras)
631 632
632 633 if merge_resp.executed:
633 634 log.debug("The merge was successful, closing the pull request.")
634 635 PullRequestModel().close_pull_request(
635 636 pull_request.pull_request_id, user)
636 637 Session().commit()
637 638 msg = _('Pull request was successfully merged and closed.')
638 639 h.flash(msg, category='success')
639 640 else:
640 641 log.debug(
641 642 "The merge was not successful. Merge response: %s",
642 643 merge_resp)
643 644 msg = PullRequestModel().merge_status_message(
644 645 merge_resp.failure_reason)
645 646 h.flash(msg, category='error')
646 647
647 648 def _update_reviewers(self, pull_request_id, review_members):
648 649 reviewers = [
649 650 (int(r['user_id']), r['reasons']) for r in review_members]
650 651 PullRequestModel().update_reviewers(pull_request_id, reviewers)
651 652 Session().commit()
652 653
653 654 def _reject_close(self, pull_request):
654 655 if pull_request.is_closed():
655 656 raise HTTPForbidden()
656 657
657 658 PullRequestModel().close_pull_request_with_comment(
658 659 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
659 660 Session().commit()
660 661
661 662 @LoginRequired()
662 663 @NotAnonymous()
663 664 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
664 665 'repository.admin')
665 666 @auth.CSRFRequired()
666 667 @jsonify
667 668 def delete(self, repo_name, pull_request_id):
668 669 pull_request_id = safe_int(pull_request_id)
669 670 pull_request = PullRequest.get_or_404(pull_request_id)
670 671 # only owner can delete it !
671 672 if pull_request.author.user_id == c.rhodecode_user.user_id:
672 673 PullRequestModel().delete(pull_request)
673 674 Session().commit()
674 675 h.flash(_('Successfully deleted pull request'),
675 676 category='success')
676 677 return redirect(url('my_account_pullrequests'))
677 678 raise HTTPForbidden()
678 679
679 680 def _get_pr_version(self, pull_request_id, version=None):
680 681 pull_request_id = safe_int(pull_request_id)
681 682 at_version = None
682 683 if version:
683 684 pull_request_ver = PullRequestVersion.get_or_404(version)
684 685 pull_request_obj = pull_request_ver
685 686 _org_pull_request_obj = pull_request_ver.pull_request
686 687 at_version = pull_request_ver.pull_request_version_id
687 688 else:
688 689 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
689 690
690 691 class PullRequestDisplay(object):
691 692 """
692 693 Special object wrapper for showing PullRequest data via Versions
693 694 It mimics PR object as close as possible. This is read only object
694 695 just for display
695 696 """
696 697 def __init__(self, attrs):
697 698 self.attrs = attrs
698 699 # internal have priority over the given ones via attrs
699 700 self.internal = ['versions']
700 701
701 702 def __getattr__(self, item):
702 703 if item in self.internal:
703 704 return getattr(self, item)
704 705 try:
705 706 return self.attrs[item]
706 707 except KeyError:
707 708 raise AttributeError(
708 709 '%s object has no attribute %s' % (self, item))
709 710
710 711 def versions(self):
711 712 return pull_request_obj.versions.order_by(
712 713 PullRequestVersion.pull_request_version_id).all()
713 714
714 715 def is_closed(self):
715 716 return pull_request_obj.is_closed()
716 717
717 attrs = UnsafeAttributeDict(pull_request_obj.get_api_data())
718 attrs = StrictAttributeDict(pull_request_obj.get_api_data())
718 719
719 attrs.author = UnsafeAttributeDict(
720 attrs.author = StrictAttributeDict(
720 721 pull_request_obj.author.get_api_data())
721 722 if pull_request_obj.target_repo:
722 attrs.target_repo = UnsafeAttributeDict(
723 attrs.target_repo = StrictAttributeDict(
723 724 pull_request_obj.target_repo.get_api_data())
724 725 attrs.target_repo.clone_url = pull_request_obj.target_repo.clone_url
725 726
726 727 if pull_request_obj.source_repo:
727 attrs.source_repo = UnsafeAttributeDict(
728 attrs.source_repo = StrictAttributeDict(
728 729 pull_request_obj.source_repo.get_api_data())
729 730 attrs.source_repo.clone_url = pull_request_obj.source_repo.clone_url
730 731
731 732 attrs.source_ref_parts = pull_request_obj.source_ref_parts
732 733 attrs.target_ref_parts = pull_request_obj.target_ref_parts
733 734
734 735 attrs.shadow_merge_ref = _org_pull_request_obj.shadow_merge_ref
735 736
736 pull_request_ver = PullRequestDisplay(attrs)
737 pull_request_display_obj = PullRequestDisplay(attrs)
737 738
738 739 return _org_pull_request_obj, pull_request_obj, \
739 pull_request_ver, at_version
740 pull_request_display_obj, at_version
740 741
741 742 @LoginRequired()
742 743 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
743 744 'repository.admin')
744 745 def show(self, repo_name, pull_request_id):
745 746 pull_request_id = safe_int(pull_request_id)
747 version = request.GET.get('version')
746 748
747 version = request.GET.get('version')
748 pull_request_latest, \
749 pull_request, \
750 pull_request_ver, \
751 at_version = self._get_pr_version(pull_request_id, version=version)
749 (pull_request_latest,
750 pull_request_at_ver,
751 pull_request_display_obj,
752 at_version) = self._get_pr_version(pull_request_id, version=version)
752 753
753 754 c.template_context['pull_request_data']['pull_request_id'] = \
754 755 pull_request_id
755 756
756 757 # pull_requests repo_name we opened it against
757 758 # ie. target_repo must match
758 if repo_name != pull_request.target_repo.repo_name:
759 if repo_name != pull_request_at_ver.target_repo.repo_name:
759 760 raise HTTPNotFound
760 761
761 762 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
762 pull_request)
763 pull_request_at_ver)
763 764
765 pr_closed = pull_request_latest.is_closed()
764 766 if at_version:
765 767 c.allowed_to_change_status = False
768 c.allowed_to_update = False
769 c.allowed_to_merge = False
770 c.allowed_to_delete = False
771 c.allowed_to_comment = False
766 772 else:
767 773 c.allowed_to_change_status = PullRequestModel(). \
768 check_user_change_status(pull_request, c.rhodecode_user)
769
770 if at_version:
771 c.allowed_to_update = False
772 else:
774 check_user_change_status(pull_request_at_ver, c.rhodecode_user)
773 775 c.allowed_to_update = PullRequestModel().check_user_update(
774 pull_request, c.rhodecode_user) and not pull_request.is_closed()
775
776 if at_version:
777 c.allowed_to_merge = False
778 else:
776 pull_request_latest, c.rhodecode_user) and not pr_closed
779 777 c.allowed_to_merge = PullRequestModel().check_user_merge(
780 pull_request, c.rhodecode_user) and not pull_request.is_closed()
781
782 if at_version:
783 c.allowed_to_delete = False
784 else:
778 pull_request_latest, c.rhodecode_user) and not pr_closed
785 779 c.allowed_to_delete = PullRequestModel().check_user_delete(
786 pull_request, c.rhodecode_user) and not pull_request.is_closed()
787
788 if at_version:
789 c.allowed_to_comment = False
790 else:
791 c.allowed_to_comment = not pull_request.is_closed()
780 pull_request_latest, c.rhodecode_user) and not pr_closed
781 c.allowed_to_comment = not pr_closed
792 782
793 783 cc_model = ChangesetCommentsModel()
794 784
795 c.pull_request_reviewers = pull_request.reviewers_statuses()
796
797 c.pull_request_review_status = pull_request.calculated_review_status()
785 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
786 c.pull_request_review_status = pull_request_at_ver.calculated_review_status()
798 787 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
799 pull_request)
788 pull_request_at_ver)
800 789 c.approval_msg = None
801 790 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
802 791 c.approval_msg = _('Reviewer approval is pending.')
803 792 c.pr_merge_status = False
804 # load compare data into template context
805 enable_comments = not pull_request.is_closed()
806 793
807 794 # inline comments
808 795 c.inline_comments = cc_model.get_inline_comments(
809 796 c.rhodecode_db_repo.repo_id,
810 797 pull_request=pull_request_id)
811 798
812 799 c.inline_cnt = cc_model.get_inline_comments_count(
813 800 c.inline_comments, version=at_version)
814 801
802 # load compare data into template context
803 enable_comments = not pr_closed
815 804 self._load_compare_data(
816 pull_request, c.inline_comments, enable_comments=enable_comments)
805 pull_request_at_ver,
806 c.inline_comments, enable_comments=enable_comments)
817 807
818 808 # outdated comments
819 809 c.outdated_comments = {}
820 810 c.outdated_cnt = 0
821 811
822 if ChangesetCommentsModel.use_outdated_comments(pull_request):
812 if ChangesetCommentsModel.use_outdated_comments(pull_request_latest):
823 813 c.outdated_comments = cc_model.get_outdated_comments(
824 814 c.rhodecode_db_repo.repo_id,
825 pull_request=pull_request)
815 pull_request=pull_request_at_ver)
826 816
827 817 # Count outdated comments and check for deleted files
828 818 for file_name, lines in c.outdated_comments.iteritems():
829 819 for comments in lines.values():
830 820 comments = [comm for comm in comments
831 821 if comm.outdated_at_version(at_version)]
832 822 c.outdated_cnt += len(comments)
833 823 if file_name not in c.included_files:
834 824 c.deleted_files.append(file_name)
835 825
836 826 # this is a hack to properly display links, when creating PR, the
837 827 # compare view and others uses different notation, and
838 828 # compare_commits.html renders links based on the target_repo.
839 829 # We need to swap that here to generate it properly on the html side
840 830 c.target_repo = c.source_repo
841 831
842 832 # comments
843 833 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
844 834 pull_request=pull_request_id)
845 835
846 836 if c.allowed_to_update:
847 837 force_close = ('forced_closed', _('Close Pull Request'))
848 838 statuses = ChangesetStatus.STATUSES + [force_close]
849 839 else:
850 840 statuses = ChangesetStatus.STATUSES
851 841 c.commit_statuses = statuses
852 842
853 843 c.ancestor = None # TODO: add ancestor here
854 c.pull_request = pull_request_ver
844 c.pull_request = pull_request_display_obj
855 845 c.pull_request_latest = pull_request_latest
856 846 c.at_version = at_version
857 847
858 848 return render('/pullrequests/pullrequest_show.html')
859 849
860 850 @LoginRequired()
861 851 @NotAnonymous()
862 852 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
863 853 'repository.admin')
864 854 @auth.CSRFRequired()
865 855 @jsonify
866 856 def comment(self, repo_name, pull_request_id):
867 857 pull_request_id = safe_int(pull_request_id)
868 858 pull_request = PullRequest.get_or_404(pull_request_id)
869 859 if pull_request.is_closed():
870 860 raise HTTPForbidden()
871 861
872 862 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
873 863 # as a changeset status, still we want to send it in one value.
874 864 status = request.POST.get('changeset_status', None)
875 865 text = request.POST.get('text')
876 866 if status and '_closed' in status:
877 867 close_pr = True
878 868 status = status.replace('_closed', '')
879 869 else:
880 870 close_pr = False
881 871
882 872 forced = (status == 'forced')
883 873 if forced:
884 874 status = 'rejected'
885 875
886 876 allowed_to_change_status = PullRequestModel().check_user_change_status(
887 877 pull_request, c.rhodecode_user)
888 878
889 879 if status and allowed_to_change_status:
890 880 message = (_('Status change %(transition_icon)s %(status)s')
891 881 % {'transition_icon': '>',
892 882 'status': ChangesetStatus.get_status_lbl(status)})
893 883 if close_pr:
894 884 message = _('Closing with') + ' ' + message
895 885 text = text or message
896 886 comm = ChangesetCommentsModel().create(
897 887 text=text,
898 888 repo=c.rhodecode_db_repo.repo_id,
899 889 user=c.rhodecode_user.user_id,
900 890 pull_request=pull_request_id,
901 891 f_path=request.POST.get('f_path'),
902 892 line_no=request.POST.get('line'),
903 893 status_change=(ChangesetStatus.get_status_lbl(status)
904 894 if status and allowed_to_change_status else None),
905 895 status_change_type=(status
906 896 if status and allowed_to_change_status else None),
907 897 closing_pr=close_pr
908 898 )
909 899
910 900 if allowed_to_change_status:
911 901 old_calculated_status = pull_request.calculated_review_status()
912 902 # get status if set !
913 903 if status:
914 904 ChangesetStatusModel().set_status(
915 905 c.rhodecode_db_repo.repo_id,
916 906 status,
917 907 c.rhodecode_user.user_id,
918 908 comm,
919 909 pull_request=pull_request_id
920 910 )
921 911
922 912 Session().flush()
923 913 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
924 914 # we now calculate the status of pull request, and based on that
925 915 # calculation we set the commits status
926 916 calculated_status = pull_request.calculated_review_status()
927 917 if old_calculated_status != calculated_status:
928 918 PullRequestModel()._trigger_pull_request_hook(
929 919 pull_request, c.rhodecode_user, 'review_status_change')
930 920
931 921 calculated_status_lbl = ChangesetStatus.get_status_lbl(
932 922 calculated_status)
933 923
934 924 if close_pr:
935 925 status_completed = (
936 926 calculated_status in [ChangesetStatus.STATUS_APPROVED,
937 927 ChangesetStatus.STATUS_REJECTED])
938 928 if forced or status_completed:
939 929 PullRequestModel().close_pull_request(
940 930 pull_request_id, c.rhodecode_user)
941 931 else:
942 932 h.flash(_('Closing pull request on other statuses than '
943 933 'rejected or approved is forbidden. '
944 934 'Calculated status from all reviewers '
945 935 'is currently: %s') % calculated_status_lbl,
946 936 category='warning')
947 937
948 938 Session().commit()
949 939
950 940 if not request.is_xhr:
951 941 return redirect(h.url('pullrequest_show', repo_name=repo_name,
952 942 pull_request_id=pull_request_id))
953 943
954 944 data = {
955 945 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
956 946 }
957 947 if comm:
958 948 c.co = comm
959 949 data.update(comm.get_dict())
960 950 data.update({'rendered_text':
961 951 render('changeset/changeset_comment_block.html')})
962 952
963 953 return data
964 954
965 955 @LoginRequired()
966 956 @NotAnonymous()
967 957 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
968 958 'repository.admin')
969 959 @auth.CSRFRequired()
970 960 @jsonify
971 961 def delete_comment(self, repo_name, comment_id):
972 962 return self._delete_comment(comment_id)
973 963
974 964 def _delete_comment(self, comment_id):
975 965 comment_id = safe_int(comment_id)
976 966 co = ChangesetComment.get_or_404(comment_id)
977 967 if co.pull_request.is_closed():
978 968 # don't allow deleting comments on closed pull request
979 969 raise HTTPForbidden()
980 970
981 971 is_owner = co.author.user_id == c.rhodecode_user.user_id
982 972 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
983 973 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
984 974 old_calculated_status = co.pull_request.calculated_review_status()
985 975 ChangesetCommentsModel().delete(comment=co)
986 976 Session().commit()
987 977 calculated_status = co.pull_request.calculated_review_status()
988 978 if old_calculated_status != calculated_status:
989 979 PullRequestModel()._trigger_pull_request_hook(
990 980 co.pull_request, c.rhodecode_user, 'review_status_change')
991 981 return True
992 982 else:
993 983 raise HTTPForbidden()
@@ -1,665 +1,668 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import difflib
23 23 from itertools import groupby
24 24
25 25 from pygments import lex
26 26 from pygments.formatters.html import _get_ttype_class as pygment_token_class
27 27 from rhodecode.lib.helpers import (
28 28 get_lexer_for_filenode, get_lexer_safe, html_escape)
29 29 from rhodecode.lib.utils2 import AttributeDict
30 30 from rhodecode.lib.vcs.nodes import FileNode
31 31 from rhodecode.lib.diff_match_patch import diff_match_patch
32 32 from rhodecode.lib.diffs import LimitedDiffContainer
33 33 from pygments.lexers import get_lexer_by_name
34 34
35 35 plain_text_lexer = get_lexer_by_name(
36 36 'text', stripall=False, stripnl=False, ensurenl=False)
37 37
38 38
39 39 log = logging.getLogger()
40 40
41 41
42 42 def filenode_as_lines_tokens(filenode, lexer=None):
43 43 lexer = lexer or get_lexer_for_filenode(filenode)
44 44 log.debug('Generating file node pygment tokens for %s, %s', lexer, filenode)
45 45 tokens = tokenize_string(filenode.content, lexer)
46 46 lines = split_token_stream(tokens, split_string='\n')
47 47 rv = list(lines)
48 48 return rv
49 49
50 50
51 51 def tokenize_string(content, lexer):
52 52 """
53 53 Use pygments to tokenize some content based on a lexer
54 54 ensuring all original new lines and whitespace is preserved
55 55 """
56 56
57 57 lexer.stripall = False
58 58 lexer.stripnl = False
59 59 lexer.ensurenl = False
60 60 for token_type, token_text in lex(content, lexer):
61 61 yield pygment_token_class(token_type), token_text
62 62
63 63
64 64 def split_token_stream(tokens, split_string=u'\n'):
65 65 """
66 66 Take a list of (TokenType, text) tuples and split them by a string
67 67
68 68 >>> split_token_stream([(TEXT, 'some\ntext'), (TEXT, 'more\n')])
69 69 [(TEXT, 'some'), (TEXT, 'text'),
70 70 (TEXT, 'more'), (TEXT, 'text')]
71 71 """
72 72
73 73 buffer = []
74 74 for token_class, token_text in tokens:
75 75 parts = token_text.split(split_string)
76 76 for part in parts[:-1]:
77 77 buffer.append((token_class, part))
78 78 yield buffer
79 79 buffer = []
80 80
81 81 buffer.append((token_class, parts[-1]))
82 82
83 83 if buffer:
84 84 yield buffer
85 85
86 86
87 87 def filenode_as_annotated_lines_tokens(filenode):
88 88 """
89 89 Take a file node and return a list of annotations => lines, if no annotation
90 90 is found, it will be None.
91 91
92 92 eg:
93 93
94 94 [
95 95 (annotation1, [
96 96 (1, line1_tokens_list),
97 97 (2, line2_tokens_list),
98 98 ]),
99 99 (annotation2, [
100 100 (3, line1_tokens_list),
101 101 ]),
102 102 (None, [
103 103 (4, line1_tokens_list),
104 104 ]),
105 105 (annotation1, [
106 106 (5, line1_tokens_list),
107 107 (6, line2_tokens_list),
108 108 ])
109 109 ]
110 110 """
111 111
112 112 commit_cache = {} # cache commit_getter lookups
113 113
114 114 def _get_annotation(commit_id, commit_getter):
115 115 if commit_id not in commit_cache:
116 116 commit_cache[commit_id] = commit_getter()
117 117 return commit_cache[commit_id]
118 118
119 119 annotation_lookup = {
120 120 line_no: _get_annotation(commit_id, commit_getter)
121 121 for line_no, commit_id, commit_getter, line_content
122 122 in filenode.annotate
123 123 }
124 124
125 125 annotations_lines = ((annotation_lookup.get(line_no), line_no, tokens)
126 126 for line_no, tokens
127 127 in enumerate(filenode_as_lines_tokens(filenode), 1))
128 128
129 129 grouped_annotations_lines = groupby(annotations_lines, lambda x: x[0])
130 130
131 131 for annotation, group in grouped_annotations_lines:
132 132 yield (
133 133 annotation, [(line_no, tokens)
134 134 for (_, line_no, tokens) in group]
135 135 )
136 136
137 137
138 138 def render_tokenstream(tokenstream):
139 139 result = []
140 140 for token_class, token_ops_texts in rollup_tokenstream(tokenstream):
141 141
142 142 if token_class:
143 143 result.append(u'<span class="%s">' % token_class)
144 144 else:
145 145 result.append(u'<span>')
146 146
147 147 for op_tag, token_text in token_ops_texts:
148 148
149 149 if op_tag:
150 150 result.append(u'<%s>' % op_tag)
151 151
152 152 escaped_text = html_escape(token_text)
153 153
154 154 # TODO: dan: investigate showing hidden characters like space/nl/tab
155 155 # escaped_text = escaped_text.replace(' ', '<sp> </sp>')
156 156 # escaped_text = escaped_text.replace('\n', '<nl>\n</nl>')
157 157 # escaped_text = escaped_text.replace('\t', '<tab>\t</tab>')
158 158
159 159 result.append(escaped_text)
160 160
161 161 if op_tag:
162 162 result.append(u'</%s>' % op_tag)
163 163
164 164 result.append(u'</span>')
165 165
166 166 html = ''.join(result)
167 167 return html
168 168
169 169
170 170 def rollup_tokenstream(tokenstream):
171 171 """
172 172 Group a token stream of the format:
173 173
174 174 ('class', 'op', 'text')
175 175 or
176 176 ('class', 'text')
177 177
178 178 into
179 179
180 180 [('class1',
181 181 [('op1', 'text'),
182 182 ('op2', 'text')]),
183 183 ('class2',
184 184 [('op3', 'text')])]
185 185
186 186 This is used to get the minimal tags necessary when
187 187 rendering to html eg for a token stream ie.
188 188
189 189 <span class="A"><ins>he</ins>llo</span>
190 190 vs
191 191 <span class="A"><ins>he</ins></span><span class="A">llo</span>
192 192
193 193 If a 2 tuple is passed in, the output op will be an empty string.
194 194
195 195 eg:
196 196
197 197 >>> rollup_tokenstream([('classA', '', 'h'),
198 198 ('classA', 'del', 'ell'),
199 199 ('classA', '', 'o'),
200 200 ('classB', '', ' '),
201 201 ('classA', '', 'the'),
202 202 ('classA', '', 're'),
203 203 ])
204 204
205 205 [('classA', [('', 'h'), ('del', 'ell'), ('', 'o')],
206 206 ('classB', [('', ' ')],
207 207 ('classA', [('', 'there')]]
208 208
209 209 """
210 210 if tokenstream and len(tokenstream[0]) == 2:
211 211 tokenstream = ((t[0], '', t[1]) for t in tokenstream)
212 212
213 213 result = []
214 214 for token_class, op_list in groupby(tokenstream, lambda t: t[0]):
215 215 ops = []
216 216 for token_op, token_text_list in groupby(op_list, lambda o: o[1]):
217 217 text_buffer = []
218 218 for t_class, t_op, t_text in token_text_list:
219 219 text_buffer.append(t_text)
220 220 ops.append((token_op, ''.join(text_buffer)))
221 221 result.append((token_class, ops))
222 222 return result
223 223
224 224
225 225 def tokens_diff(old_tokens, new_tokens, use_diff_match_patch=True):
226 226 """
227 227 Converts a list of (token_class, token_text) tuples to a list of
228 228 (token_class, token_op, token_text) tuples where token_op is one of
229 229 ('ins', 'del', '')
230 230
231 231 :param old_tokens: list of (token_class, token_text) tuples of old line
232 232 :param new_tokens: list of (token_class, token_text) tuples of new line
233 233 :param use_diff_match_patch: boolean, will use google's diff match patch
234 234 library which has options to 'smooth' out the character by character
235 235 differences making nicer ins/del blocks
236 236 """
237 237
238 238 old_tokens_result = []
239 239 new_tokens_result = []
240 240
241 241 similarity = difflib.SequenceMatcher(None,
242 242 ''.join(token_text for token_class, token_text in old_tokens),
243 243 ''.join(token_text for token_class, token_text in new_tokens)
244 244 ).ratio()
245 245
246 246 if similarity < 0.6: # return, the blocks are too different
247 247 for token_class, token_text in old_tokens:
248 248 old_tokens_result.append((token_class, '', token_text))
249 249 for token_class, token_text in new_tokens:
250 250 new_tokens_result.append((token_class, '', token_text))
251 251 return old_tokens_result, new_tokens_result, similarity
252 252
253 253 token_sequence_matcher = difflib.SequenceMatcher(None,
254 254 [x[1] for x in old_tokens],
255 255 [x[1] for x in new_tokens])
256 256
257 257 for tag, o1, o2, n1, n2 in token_sequence_matcher.get_opcodes():
258 258 # check the differences by token block types first to give a more
259 259 # nicer "block" level replacement vs character diffs
260 260
261 261 if tag == 'equal':
262 262 for token_class, token_text in old_tokens[o1:o2]:
263 263 old_tokens_result.append((token_class, '', token_text))
264 264 for token_class, token_text in new_tokens[n1:n2]:
265 265 new_tokens_result.append((token_class, '', token_text))
266 266 elif tag == 'delete':
267 267 for token_class, token_text in old_tokens[o1:o2]:
268 268 old_tokens_result.append((token_class, 'del', token_text))
269 269 elif tag == 'insert':
270 270 for token_class, token_text in new_tokens[n1:n2]:
271 271 new_tokens_result.append((token_class, 'ins', token_text))
272 272 elif tag == 'replace':
273 273 # if same type token blocks must be replaced, do a diff on the
274 274 # characters in the token blocks to show individual changes
275 275
276 276 old_char_tokens = []
277 277 new_char_tokens = []
278 278 for token_class, token_text in old_tokens[o1:o2]:
279 279 for char in token_text:
280 280 old_char_tokens.append((token_class, char))
281 281
282 282 for token_class, token_text in new_tokens[n1:n2]:
283 283 for char in token_text:
284 284 new_char_tokens.append((token_class, char))
285 285
286 286 old_string = ''.join([token_text for
287 287 token_class, token_text in old_char_tokens])
288 288 new_string = ''.join([token_text for
289 289 token_class, token_text in new_char_tokens])
290 290
291 291 char_sequence = difflib.SequenceMatcher(
292 292 None, old_string, new_string)
293 293 copcodes = char_sequence.get_opcodes()
294 294 obuffer, nbuffer = [], []
295 295
296 296 if use_diff_match_patch:
297 297 dmp = diff_match_patch()
298 298 dmp.Diff_EditCost = 11 # TODO: dan: extract this to a setting
299 299 reps = dmp.diff_main(old_string, new_string)
300 300 dmp.diff_cleanupEfficiency(reps)
301 301
302 302 a, b = 0, 0
303 303 for op, rep in reps:
304 304 l = len(rep)
305 305 if op == 0:
306 306 for i, c in enumerate(rep):
307 307 obuffer.append((old_char_tokens[a+i][0], '', c))
308 308 nbuffer.append((new_char_tokens[b+i][0], '', c))
309 309 a += l
310 310 b += l
311 311 elif op == -1:
312 312 for i, c in enumerate(rep):
313 313 obuffer.append((old_char_tokens[a+i][0], 'del', c))
314 314 a += l
315 315 elif op == 1:
316 316 for i, c in enumerate(rep):
317 317 nbuffer.append((new_char_tokens[b+i][0], 'ins', c))
318 318 b += l
319 319 else:
320 320 for ctag, co1, co2, cn1, cn2 in copcodes:
321 321 if ctag == 'equal':
322 322 for token_class, token_text in old_char_tokens[co1:co2]:
323 323 obuffer.append((token_class, '', token_text))
324 324 for token_class, token_text in new_char_tokens[cn1:cn2]:
325 325 nbuffer.append((token_class, '', token_text))
326 326 elif ctag == 'delete':
327 327 for token_class, token_text in old_char_tokens[co1:co2]:
328 328 obuffer.append((token_class, 'del', token_text))
329 329 elif ctag == 'insert':
330 330 for token_class, token_text in new_char_tokens[cn1:cn2]:
331 331 nbuffer.append((token_class, 'ins', token_text))
332 332 elif ctag == 'replace':
333 333 for token_class, token_text in old_char_tokens[co1:co2]:
334 334 obuffer.append((token_class, 'del', token_text))
335 335 for token_class, token_text in new_char_tokens[cn1:cn2]:
336 336 nbuffer.append((token_class, 'ins', token_text))
337 337
338 338 old_tokens_result.extend(obuffer)
339 339 new_tokens_result.extend(nbuffer)
340 340
341 341 return old_tokens_result, new_tokens_result, similarity
342 342
343 343
344 344 class DiffSet(object):
345 345 """
346 346 An object for parsing the diff result from diffs.DiffProcessor and
347 347 adding highlighting, side by side/unified renderings and line diffs
348 348 """
349 349
350 350 HL_REAL = 'REAL' # highlights using original file, slow
351 351 HL_FAST = 'FAST' # highlights using just the line, fast but not correct
352 352 # in the case of multiline code
353 353 HL_NONE = 'NONE' # no highlighting, fastest
354 354
355 355 def __init__(self, highlight_mode=HL_REAL, repo_name=None,
356 source_repo_name=None,
356 357 source_node_getter=lambda filename: None,
357 358 target_node_getter=lambda filename: None,
358 359 source_nodes=None, target_nodes=None,
359 360 max_file_size_limit=150 * 1024, # files over this size will
360 361 # use fast highlighting
361 362 comments=None,
362 363 ):
363 364
364 365 self.highlight_mode = highlight_mode
365 366 self.highlighted_filenodes = {}
366 367 self.source_node_getter = source_node_getter
367 368 self.target_node_getter = target_node_getter
368 369 self.source_nodes = source_nodes or {}
369 370 self.target_nodes = target_nodes or {}
370 371 self.repo_name = repo_name
372 self.source_repo_name = source_repo_name or repo_name
371 373 self.comments = comments or {}
372 374 self.max_file_size_limit = max_file_size_limit
373 375
374 376 def render_patchset(self, patchset, source_ref=None, target_ref=None):
375 377 diffset = AttributeDict(dict(
376 378 lines_added=0,
377 379 lines_deleted=0,
378 380 changed_files=0,
379 381 files=[],
380 382 limited_diff=isinstance(patchset, LimitedDiffContainer),
381 383 repo_name=self.repo_name,
384 source_repo_name=self.source_repo_name,
382 385 source_ref=source_ref,
383 386 target_ref=target_ref,
384 387 ))
385 388 for patch in patchset:
386 389 filediff = self.render_patch(patch)
387 390 filediff.diffset = diffset
388 391 diffset.files.append(filediff)
389 392 diffset.changed_files += 1
390 393 if not patch['stats']['binary']:
391 394 diffset.lines_added += patch['stats']['added']
392 395 diffset.lines_deleted += patch['stats']['deleted']
393 396
394 397 return diffset
395 398
396 399 _lexer_cache = {}
397 400 def _get_lexer_for_filename(self, filename):
398 401 # cached because we might need to call it twice for source/target
399 402 if filename not in self._lexer_cache:
400 403 self._lexer_cache[filename] = get_lexer_safe(filepath=filename)
401 404 return self._lexer_cache[filename]
402 405
403 406 def render_patch(self, patch):
404 407 log.debug('rendering diff for %r' % patch['filename'])
405 408
406 409 source_filename = patch['original_filename']
407 410 target_filename = patch['filename']
408 411
409 412 source_lexer = plain_text_lexer
410 413 target_lexer = plain_text_lexer
411 414
412 415 if not patch['stats']['binary']:
413 416 if self.highlight_mode == self.HL_REAL:
414 417 if (source_filename and patch['operation'] in ('D', 'M')
415 418 and source_filename not in self.source_nodes):
416 419 self.source_nodes[source_filename] = (
417 420 self.source_node_getter(source_filename))
418 421
419 422 if (target_filename and patch['operation'] in ('A', 'M')
420 423 and target_filename not in self.target_nodes):
421 424 self.target_nodes[target_filename] = (
422 425 self.target_node_getter(target_filename))
423 426
424 427 elif self.highlight_mode == self.HL_FAST:
425 428 source_lexer = self._get_lexer_for_filename(source_filename)
426 429 target_lexer = self._get_lexer_for_filename(target_filename)
427 430
428 431 source_file = self.source_nodes.get(source_filename, source_filename)
429 432 target_file = self.target_nodes.get(target_filename, target_filename)
430 433
431 434 source_filenode, target_filenode = None, None
432 435
433 436 # TODO: dan: FileNode.lexer works on the content of the file - which
434 437 # can be slow - issue #4289 explains a lexer clean up - which once
435 438 # done can allow caching a lexer for a filenode to avoid the file lookup
436 439 if isinstance(source_file, FileNode):
437 440 source_filenode = source_file
438 441 source_lexer = source_file.lexer
439 442 if isinstance(target_file, FileNode):
440 443 target_filenode = target_file
441 444 target_lexer = target_file.lexer
442 445
443 446 source_file_path, target_file_path = None, None
444 447
445 448 if source_filename != '/dev/null':
446 449 source_file_path = source_filename
447 450 if target_filename != '/dev/null':
448 451 target_file_path = target_filename
449 452
450 453 source_file_type = source_lexer.name
451 454 target_file_type = target_lexer.name
452 455
453 456 op_hunks = patch['chunks'][0]
454 457 hunks = patch['chunks'][1:]
455 458
456 459 filediff = AttributeDict({
457 460 'source_file_path': source_file_path,
458 461 'target_file_path': target_file_path,
459 462 'source_filenode': source_filenode,
460 463 'target_filenode': target_filenode,
461 464 'hunks': [],
462 465 'source_file_type': target_file_type,
463 466 'target_file_type': source_file_type,
464 467 'patch': patch,
465 468 'source_mode': patch['stats']['old_mode'],
466 469 'target_mode': patch['stats']['new_mode'],
467 470 'limited_diff': isinstance(patch, LimitedDiffContainer),
468 471 'diffset': self,
469 472 })
470 473
471 474 for hunk in hunks:
472 475 hunkbit = self.parse_hunk(hunk, source_file, target_file)
473 476 hunkbit.filediff = filediff
474 477 filediff.hunks.append(hunkbit)
475 478 return filediff
476 479
477 480 def parse_hunk(self, hunk, source_file, target_file):
478 481 result = AttributeDict(dict(
479 482 source_start=hunk['source_start'],
480 483 source_length=hunk['source_length'],
481 484 target_start=hunk['target_start'],
482 485 target_length=hunk['target_length'],
483 486 section_header=hunk['section_header'],
484 487 lines=[],
485 488 ))
486 489 before, after = [], []
487 490
488 491 for line in hunk['lines']:
489 492 if line['action'] == 'unmod':
490 493 result.lines.extend(
491 494 self.parse_lines(before, after, source_file, target_file))
492 495 after.append(line)
493 496 before.append(line)
494 497 elif line['action'] == 'add':
495 498 after.append(line)
496 499 elif line['action'] == 'del':
497 500 before.append(line)
498 501 elif line['action'] == 'old-no-nl':
499 502 before.append(line)
500 503 elif line['action'] == 'new-no-nl':
501 504 after.append(line)
502 505
503 506 result.lines.extend(
504 507 self.parse_lines(before, after, source_file, target_file))
505 508 result.unified = self.as_unified(result.lines)
506 509 result.sideside = result.lines
507 510 return result
508 511
509 512 def parse_lines(self, before_lines, after_lines, source_file, target_file):
510 513 # TODO: dan: investigate doing the diff comparison and fast highlighting
511 514 # on the entire before and after buffered block lines rather than by
512 515 # line, this means we can get better 'fast' highlighting if the context
513 516 # allows it - eg.
514 517 # line 4: """
515 518 # line 5: this gets highlighted as a string
516 519 # line 6: """
517 520
518 521 lines = []
519 522 while before_lines or after_lines:
520 523 before, after = None, None
521 524 before_tokens, after_tokens = None, None
522 525
523 526 if before_lines:
524 527 before = before_lines.pop(0)
525 528 if after_lines:
526 529 after = after_lines.pop(0)
527 530
528 531 original = AttributeDict()
529 532 modified = AttributeDict()
530 533
531 534 if before:
532 535 if before['action'] == 'old-no-nl':
533 536 before_tokens = [('nonl', before['line'])]
534 537 else:
535 538 before_tokens = self.get_line_tokens(
536 539 line_text=before['line'], line_number=before['old_lineno'],
537 540 file=source_file)
538 541 original.lineno = before['old_lineno']
539 542 original.content = before['line']
540 543 original.action = self.action_to_op(before['action'])
541 544 original.comments = self.get_comments_for('old',
542 545 source_file, before['old_lineno'])
543 546
544 547 if after:
545 548 if after['action'] == 'new-no-nl':
546 549 after_tokens = [('nonl', after['line'])]
547 550 else:
548 551 after_tokens = self.get_line_tokens(
549 552 line_text=after['line'], line_number=after['new_lineno'],
550 553 file=target_file)
551 554 modified.lineno = after['new_lineno']
552 555 modified.content = after['line']
553 556 modified.action = self.action_to_op(after['action'])
554 557 modified.comments = self.get_comments_for('new',
555 558 target_file, after['new_lineno'])
556 559
557 560 # diff the lines
558 561 if before_tokens and after_tokens:
559 562 o_tokens, m_tokens, similarity = tokens_diff(
560 563 before_tokens, after_tokens)
561 564 original.content = render_tokenstream(o_tokens)
562 565 modified.content = render_tokenstream(m_tokens)
563 566 elif before_tokens:
564 567 original.content = render_tokenstream(
565 568 [(x[0], '', x[1]) for x in before_tokens])
566 569 elif after_tokens:
567 570 modified.content = render_tokenstream(
568 571 [(x[0], '', x[1]) for x in after_tokens])
569 572
570 573 lines.append(AttributeDict({
571 574 'original': original,
572 575 'modified': modified,
573 576 }))
574 577
575 578 return lines
576 579
577 580 def get_comments_for(self, version, file, line_number):
578 581 if hasattr(file, 'unicode_path'):
579 582 file = file.unicode_path
580 583
581 584 if not isinstance(file, basestring):
582 585 return None
583 586
584 587 line_key = {
585 588 'old': 'o',
586 589 'new': 'n',
587 590 }[version] + str(line_number)
588 591
589 592 return self.comments.get(file, {}).get(line_key)
590 593
591 594 def get_line_tokens(self, line_text, line_number, file=None):
592 595 filenode = None
593 596 filename = None
594 597
595 598 if isinstance(file, basestring):
596 599 filename = file
597 600 elif isinstance(file, FileNode):
598 601 filenode = file
599 602 filename = file.unicode_path
600 603
601 604 if self.highlight_mode == self.HL_REAL and filenode:
602 605 if line_number and file.size < self.max_file_size_limit:
603 606 return self.get_tokenized_filenode_line(file, line_number)
604 607
605 608 if self.highlight_mode in (self.HL_REAL, self.HL_FAST) and filename:
606 609 lexer = self._get_lexer_for_filename(filename)
607 610 return list(tokenize_string(line_text, lexer))
608 611
609 612 return list(tokenize_string(line_text, plain_text_lexer))
610 613
611 614 def get_tokenized_filenode_line(self, filenode, line_number):
612 615
613 616 if filenode not in self.highlighted_filenodes:
614 617 tokenized_lines = filenode_as_lines_tokens(filenode, filenode.lexer)
615 618 self.highlighted_filenodes[filenode] = tokenized_lines
616 619 return self.highlighted_filenodes[filenode][line_number - 1]
617 620
618 621 def action_to_op(self, action):
619 622 return {
620 623 'add': '+',
621 624 'del': '-',
622 625 'unmod': ' ',
623 626 'old-no-nl': ' ',
624 627 'new-no-nl': ' ',
625 628 }.get(action, action)
626 629
627 630 def as_unified(self, lines):
628 631 """ Return a generator that yields the lines of a diff in unified order """
629 632 def generator():
630 633 buf = []
631 634 for line in lines:
632 635
633 636 if buf and not line.original or line.original.action == ' ':
634 637 for b in buf:
635 638 yield b
636 639 buf = []
637 640
638 641 if line.original:
639 642 if line.original.action == ' ':
640 643 yield (line.original.lineno, line.modified.lineno,
641 644 line.original.action, line.original.content,
642 645 line.original.comments)
643 646 continue
644 647
645 648 if line.original.action == '-':
646 649 yield (line.original.lineno, None,
647 650 line.original.action, line.original.content,
648 651 line.original.comments)
649 652
650 653 if line.modified.action == '+':
651 654 buf.append((
652 655 None, line.modified.lineno,
653 656 line.modified.action, line.modified.content,
654 657 line.modified.comments))
655 658 continue
656 659
657 660 if line.modified:
658 661 yield (None, line.modified.lineno,
659 662 line.modified.action, line.modified.content,
660 663 line.modified.comments)
661 664
662 665 for b in buf:
663 666 yield b
664 667
665 668 return generator()
@@ -1,946 +1,950 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 Some simple helper functions
24 24 """
25 25
26 26
27 27 import collections
28 28 import datetime
29 29 import dateutil.relativedelta
30 30 import hashlib
31 31 import logging
32 32 import re
33 33 import sys
34 34 import time
35 35 import threading
36 36 import urllib
37 37 import urlobject
38 38 import uuid
39 39
40 40 import pygments.lexers
41 41 import sqlalchemy
42 42 import sqlalchemy.engine.url
43 43 import webob
44 44 import routes.util
45 45
46 46 import rhodecode
47 47
48 48
49 49 def md5(s):
50 50 return hashlib.md5(s).hexdigest()
51 51
52 52
53 53 def md5_safe(s):
54 54 return md5(safe_str(s))
55 55
56 56
57 57 def __get_lem(extra_mapping=None):
58 58 """
59 59 Get language extension map based on what's inside pygments lexers
60 60 """
61 61 d = collections.defaultdict(lambda: [])
62 62
63 63 def __clean(s):
64 64 s = s.lstrip('*')
65 65 s = s.lstrip('.')
66 66
67 67 if s.find('[') != -1:
68 68 exts = []
69 69 start, stop = s.find('['), s.find(']')
70 70
71 71 for suffix in s[start + 1:stop]:
72 72 exts.append(s[:s.find('[')] + suffix)
73 73 return [e.lower() for e in exts]
74 74 else:
75 75 return [s.lower()]
76 76
77 77 for lx, t in sorted(pygments.lexers.LEXERS.items()):
78 78 m = map(__clean, t[-2])
79 79 if m:
80 80 m = reduce(lambda x, y: x + y, m)
81 81 for ext in m:
82 82 desc = lx.replace('Lexer', '')
83 83 d[ext].append(desc)
84 84
85 85 data = dict(d)
86 86
87 87 extra_mapping = extra_mapping or {}
88 88 if extra_mapping:
89 89 for k, v in extra_mapping.items():
90 90 if k not in data:
91 91 # register new mapping2lexer
92 92 data[k] = [v]
93 93
94 94 return data
95 95
96 96
97 97 def str2bool(_str):
98 98 """
99 99 returns True/False value from given string, it tries to translate the
100 100 string into boolean
101 101
102 102 :param _str: string value to translate into boolean
103 103 :rtype: boolean
104 104 :returns: boolean from given string
105 105 """
106 106 if _str is None:
107 107 return False
108 108 if _str in (True, False):
109 109 return _str
110 110 _str = str(_str).strip().lower()
111 111 return _str in ('t', 'true', 'y', 'yes', 'on', '1')
112 112
113 113
114 114 def aslist(obj, sep=None, strip=True):
115 115 """
116 116 Returns given string separated by sep as list
117 117
118 118 :param obj:
119 119 :param sep:
120 120 :param strip:
121 121 """
122 122 if isinstance(obj, (basestring,)):
123 123 lst = obj.split(sep)
124 124 if strip:
125 125 lst = [v.strip() for v in lst]
126 126 return lst
127 127 elif isinstance(obj, (list, tuple)):
128 128 return obj
129 129 elif obj is None:
130 130 return []
131 131 else:
132 132 return [obj]
133 133
134 134
135 135 def convert_line_endings(line, mode):
136 136 """
137 137 Converts a given line "line end" accordingly to given mode
138 138
139 139 Available modes are::
140 140 0 - Unix
141 141 1 - Mac
142 142 2 - DOS
143 143
144 144 :param line: given line to convert
145 145 :param mode: mode to convert to
146 146 :rtype: str
147 147 :return: converted line according to mode
148 148 """
149 149 if mode == 0:
150 150 line = line.replace('\r\n', '\n')
151 151 line = line.replace('\r', '\n')
152 152 elif mode == 1:
153 153 line = line.replace('\r\n', '\r')
154 154 line = line.replace('\n', '\r')
155 155 elif mode == 2:
156 156 line = re.sub('\r(?!\n)|(?<!\r)\n', '\r\n', line)
157 157 return line
158 158
159 159
160 160 def detect_mode(line, default):
161 161 """
162 162 Detects line break for given line, if line break couldn't be found
163 163 given default value is returned
164 164
165 165 :param line: str line
166 166 :param default: default
167 167 :rtype: int
168 168 :return: value of line end on of 0 - Unix, 1 - Mac, 2 - DOS
169 169 """
170 170 if line.endswith('\r\n'):
171 171 return 2
172 172 elif line.endswith('\n'):
173 173 return 0
174 174 elif line.endswith('\r'):
175 175 return 1
176 176 else:
177 177 return default
178 178
179 179
180 180 def safe_int(val, default=None):
181 181 """
182 182 Returns int() of val if val is not convertable to int use default
183 183 instead
184 184
185 185 :param val:
186 186 :param default:
187 187 """
188 188
189 189 try:
190 190 val = int(val)
191 191 except (ValueError, TypeError):
192 192 val = default
193 193
194 194 return val
195 195
196 196
197 197 def safe_unicode(str_, from_encoding=None):
198 198 """
199 199 safe unicode function. Does few trick to turn str_ into unicode
200 200
201 201 In case of UnicodeDecode error, we try to return it with encoding detected
202 202 by chardet library if it fails fallback to unicode with errors replaced
203 203
204 204 :param str_: string to decode
205 205 :rtype: unicode
206 206 :returns: unicode object
207 207 """
208 208 if isinstance(str_, unicode):
209 209 return str_
210 210
211 211 if not from_encoding:
212 212 DEFAULT_ENCODINGS = aslist(rhodecode.CONFIG.get('default_encoding',
213 213 'utf8'), sep=',')
214 214 from_encoding = DEFAULT_ENCODINGS
215 215
216 216 if not isinstance(from_encoding, (list, tuple)):
217 217 from_encoding = [from_encoding]
218 218
219 219 try:
220 220 return unicode(str_)
221 221 except UnicodeDecodeError:
222 222 pass
223 223
224 224 for enc in from_encoding:
225 225 try:
226 226 return unicode(str_, enc)
227 227 except UnicodeDecodeError:
228 228 pass
229 229
230 230 try:
231 231 import chardet
232 232 encoding = chardet.detect(str_)['encoding']
233 233 if encoding is None:
234 234 raise Exception()
235 235 return str_.decode(encoding)
236 236 except (ImportError, UnicodeDecodeError, Exception):
237 237 return unicode(str_, from_encoding[0], 'replace')
238 238
239 239
240 240 def safe_str(unicode_, to_encoding=None):
241 241 """
242 242 safe str function. Does few trick to turn unicode_ into string
243 243
244 244 In case of UnicodeEncodeError, we try to return it with encoding detected
245 245 by chardet library if it fails fallback to string with errors replaced
246 246
247 247 :param unicode_: unicode to encode
248 248 :rtype: str
249 249 :returns: str object
250 250 """
251 251
252 252 # if it's not basestr cast to str
253 253 if not isinstance(unicode_, basestring):
254 254 return str(unicode_)
255 255
256 256 if isinstance(unicode_, str):
257 257 return unicode_
258 258
259 259 if not to_encoding:
260 260 DEFAULT_ENCODINGS = aslist(rhodecode.CONFIG.get('default_encoding',
261 261 'utf8'), sep=',')
262 262 to_encoding = DEFAULT_ENCODINGS
263 263
264 264 if not isinstance(to_encoding, (list, tuple)):
265 265 to_encoding = [to_encoding]
266 266
267 267 for enc in to_encoding:
268 268 try:
269 269 return unicode_.encode(enc)
270 270 except UnicodeEncodeError:
271 271 pass
272 272
273 273 try:
274 274 import chardet
275 275 encoding = chardet.detect(unicode_)['encoding']
276 276 if encoding is None:
277 277 raise UnicodeEncodeError()
278 278
279 279 return unicode_.encode(encoding)
280 280 except (ImportError, UnicodeEncodeError):
281 281 return unicode_.encode(to_encoding[0], 'replace')
282 282
283 283
284 284 def remove_suffix(s, suffix):
285 285 if s.endswith(suffix):
286 286 s = s[:-1 * len(suffix)]
287 287 return s
288 288
289 289
290 290 def remove_prefix(s, prefix):
291 291 if s.startswith(prefix):
292 292 s = s[len(prefix):]
293 293 return s
294 294
295 295
296 296 def find_calling_context(ignore_modules=None):
297 297 """
298 298 Look through the calling stack and return the frame which called
299 299 this function and is part of core module ( ie. rhodecode.* )
300 300
301 301 :param ignore_modules: list of modules to ignore eg. ['rhodecode.lib']
302 302 """
303 303
304 304 ignore_modules = ignore_modules or []
305 305
306 306 f = sys._getframe(2)
307 307 while f.f_back is not None:
308 308 name = f.f_globals.get('__name__')
309 309 if name and name.startswith(__name__.split('.')[0]):
310 310 if name not in ignore_modules:
311 311 return f
312 312 f = f.f_back
313 313 return None
314 314
315 315
316 316 def engine_from_config(configuration, prefix='sqlalchemy.', **kwargs):
317 317 """Custom engine_from_config functions."""
318 318 log = logging.getLogger('sqlalchemy.engine')
319 319 engine = sqlalchemy.engine_from_config(configuration, prefix, **kwargs)
320 320
321 321 def color_sql(sql):
322 322 color_seq = '\033[1;33m' # This is yellow: code 33
323 323 normal = '\x1b[0m'
324 324 return ''.join([color_seq, sql, normal])
325 325
326 326 if configuration['debug']:
327 327 # attach events only for debug configuration
328 328
329 329 def before_cursor_execute(conn, cursor, statement,
330 330 parameters, context, executemany):
331 331 setattr(conn, 'query_start_time', time.time())
332 332 log.info(color_sql(">>>>> STARTING QUERY >>>>>"))
333 333 calling_context = find_calling_context(ignore_modules=[
334 334 'rhodecode.lib.caching_query',
335 335 'rhodecode.model.settings',
336 336 ])
337 337 if calling_context:
338 338 log.info(color_sql('call context %s:%s' % (
339 339 calling_context.f_code.co_filename,
340 340 calling_context.f_lineno,
341 341 )))
342 342
343 343 def after_cursor_execute(conn, cursor, statement,
344 344 parameters, context, executemany):
345 345 delattr(conn, 'query_start_time')
346 346
347 347 sqlalchemy.event.listen(engine, "before_cursor_execute",
348 348 before_cursor_execute)
349 349 sqlalchemy.event.listen(engine, "after_cursor_execute",
350 350 after_cursor_execute)
351 351
352 352 return engine
353 353
354 354
355 355 def get_encryption_key(config):
356 356 secret = config.get('rhodecode.encrypted_values.secret')
357 357 default = config['beaker.session.secret']
358 358 return secret or default
359 359
360 360
361 361 def age(prevdate, now=None, show_short_version=False, show_suffix=True,
362 362 short_format=False):
363 363 """
364 364 Turns a datetime into an age string.
365 365 If show_short_version is True, this generates a shorter string with
366 366 an approximate age; ex. '1 day ago', rather than '1 day and 23 hours ago'.
367 367
368 368 * IMPORTANT*
369 369 Code of this function is written in special way so it's easier to
370 370 backport it to javascript. If you mean to update it, please also update
371 371 `jquery.timeago-extension.js` file
372 372
373 373 :param prevdate: datetime object
374 374 :param now: get current time, if not define we use
375 375 `datetime.datetime.now()`
376 376 :param show_short_version: if it should approximate the date and
377 377 return a shorter string
378 378 :param show_suffix:
379 379 :param short_format: show short format, eg 2D instead of 2 days
380 380 :rtype: unicode
381 381 :returns: unicode words describing age
382 382 """
383 383 from pylons.i18n.translation import _, ungettext
384 384
385 385 def _get_relative_delta(now, prevdate):
386 386 base = dateutil.relativedelta.relativedelta(now, prevdate)
387 387 return {
388 388 'year': base.years,
389 389 'month': base.months,
390 390 'day': base.days,
391 391 'hour': base.hours,
392 392 'minute': base.minutes,
393 393 'second': base.seconds,
394 394 }
395 395
396 396 def _is_leap_year(year):
397 397 return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
398 398
399 399 def get_month(prevdate):
400 400 return prevdate.month
401 401
402 402 def get_year(prevdate):
403 403 return prevdate.year
404 404
405 405 now = now or datetime.datetime.now()
406 406 order = ['year', 'month', 'day', 'hour', 'minute', 'second']
407 407 deltas = {}
408 408 future = False
409 409
410 410 if prevdate > now:
411 411 now_old = now
412 412 now = prevdate
413 413 prevdate = now_old
414 414 future = True
415 415 if future:
416 416 prevdate = prevdate.replace(microsecond=0)
417 417 # Get date parts deltas
418 418 for part in order:
419 419 rel_delta = _get_relative_delta(now, prevdate)
420 420 deltas[part] = rel_delta[part]
421 421
422 422 # Fix negative offsets (there is 1 second between 10:59:59 and 11:00:00,
423 423 # not 1 hour, -59 minutes and -59 seconds)
424 424 offsets = [[5, 60], [4, 60], [3, 24]]
425 425 for element in offsets: # seconds, minutes, hours
426 426 num = element[0]
427 427 length = element[1]
428 428
429 429 part = order[num]
430 430 carry_part = order[num - 1]
431 431
432 432 if deltas[part] < 0:
433 433 deltas[part] += length
434 434 deltas[carry_part] -= 1
435 435
436 436 # Same thing for days except that the increment depends on the (variable)
437 437 # number of days in the month
438 438 month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
439 439 if deltas['day'] < 0:
440 440 if get_month(prevdate) == 2 and _is_leap_year(get_year(prevdate)):
441 441 deltas['day'] += 29
442 442 else:
443 443 deltas['day'] += month_lengths[get_month(prevdate) - 1]
444 444
445 445 deltas['month'] -= 1
446 446
447 447 if deltas['month'] < 0:
448 448 deltas['month'] += 12
449 449 deltas['year'] -= 1
450 450
451 451 # Format the result
452 452 if short_format:
453 453 fmt_funcs = {
454 454 'year': lambda d: u'%dy' % d,
455 455 'month': lambda d: u'%dm' % d,
456 456 'day': lambda d: u'%dd' % d,
457 457 'hour': lambda d: u'%dh' % d,
458 458 'minute': lambda d: u'%dmin' % d,
459 459 'second': lambda d: u'%dsec' % d,
460 460 }
461 461 else:
462 462 fmt_funcs = {
463 463 'year': lambda d: ungettext(u'%d year', '%d years', d) % d,
464 464 'month': lambda d: ungettext(u'%d month', '%d months', d) % d,
465 465 'day': lambda d: ungettext(u'%d day', '%d days', d) % d,
466 466 'hour': lambda d: ungettext(u'%d hour', '%d hours', d) % d,
467 467 'minute': lambda d: ungettext(u'%d minute', '%d minutes', d) % d,
468 468 'second': lambda d: ungettext(u'%d second', '%d seconds', d) % d,
469 469 }
470 470
471 471 i = 0
472 472 for part in order:
473 473 value = deltas[part]
474 474 if value != 0:
475 475
476 476 if i < 5:
477 477 sub_part = order[i + 1]
478 478 sub_value = deltas[sub_part]
479 479 else:
480 480 sub_value = 0
481 481
482 482 if sub_value == 0 or show_short_version:
483 483 _val = fmt_funcs[part](value)
484 484 if future:
485 485 if show_suffix:
486 486 return _(u'in %s') % _val
487 487 else:
488 488 return _val
489 489
490 490 else:
491 491 if show_suffix:
492 492 return _(u'%s ago') % _val
493 493 else:
494 494 return _val
495 495
496 496 val = fmt_funcs[part](value)
497 497 val_detail = fmt_funcs[sub_part](sub_value)
498 498
499 499 if short_format:
500 500 datetime_tmpl = u'%s, %s'
501 501 if show_suffix:
502 502 datetime_tmpl = _(u'%s, %s ago')
503 503 if future:
504 504 datetime_tmpl = _(u'in %s, %s')
505 505 else:
506 506 datetime_tmpl = _(u'%s and %s')
507 507 if show_suffix:
508 508 datetime_tmpl = _(u'%s and %s ago')
509 509 if future:
510 510 datetime_tmpl = _(u'in %s and %s')
511 511
512 512 return datetime_tmpl % (val, val_detail)
513 513 i += 1
514 514 return _(u'just now')
515 515
516 516
517 517 def uri_filter(uri):
518 518 """
519 519 Removes user:password from given url string
520 520
521 521 :param uri:
522 522 :rtype: unicode
523 523 :returns: filtered list of strings
524 524 """
525 525 if not uri:
526 526 return ''
527 527
528 528 proto = ''
529 529
530 530 for pat in ('https://', 'http://'):
531 531 if uri.startswith(pat):
532 532 uri = uri[len(pat):]
533 533 proto = pat
534 534 break
535 535
536 536 # remove passwords and username
537 537 uri = uri[uri.find('@') + 1:]
538 538
539 539 # get the port
540 540 cred_pos = uri.find(':')
541 541 if cred_pos == -1:
542 542 host, port = uri, None
543 543 else:
544 544 host, port = uri[:cred_pos], uri[cred_pos + 1:]
545 545
546 546 return filter(None, [proto, host, port])
547 547
548 548
549 549 def credentials_filter(uri):
550 550 """
551 551 Returns a url with removed credentials
552 552
553 553 :param uri:
554 554 """
555 555
556 556 uri = uri_filter(uri)
557 557 # check if we have port
558 558 if len(uri) > 2 and uri[2]:
559 559 uri[2] = ':' + uri[2]
560 560
561 561 return ''.join(uri)
562 562
563 563
564 564 def get_clone_url(uri_tmpl, qualifed_home_url, repo_name, repo_id, **override):
565 565 parsed_url = urlobject.URLObject(qualifed_home_url)
566 566 decoded_path = safe_unicode(urllib.unquote(parsed_url.path.rstrip('/')))
567 567 args = {
568 568 'scheme': parsed_url.scheme,
569 569 'user': '',
570 570 # path if we use proxy-prefix
571 571 'netloc': parsed_url.netloc+decoded_path,
572 572 'prefix': decoded_path,
573 573 'repo': repo_name,
574 574 'repoid': str(repo_id)
575 575 }
576 576 args.update(override)
577 577 args['user'] = urllib.quote(safe_str(args['user']))
578 578
579 579 for k, v in args.items():
580 580 uri_tmpl = uri_tmpl.replace('{%s}' % k, v)
581 581
582 582 # remove leading @ sign if it's present. Case of empty user
583 583 url_obj = urlobject.URLObject(uri_tmpl)
584 584 url = url_obj.with_netloc(url_obj.netloc.lstrip('@'))
585 585
586 586 return safe_unicode(url)
587 587
588 588
589 589 def get_commit_safe(repo, commit_id=None, commit_idx=None, pre_load=None):
590 590 """
591 591 Safe version of get_commit if this commit doesn't exists for a
592 592 repository it returns a Dummy one instead
593 593
594 594 :param repo: repository instance
595 595 :param commit_id: commit id as str
596 596 :param pre_load: optional list of commit attributes to load
597 597 """
598 598 # TODO(skreft): remove these circular imports
599 599 from rhodecode.lib.vcs.backends.base import BaseRepository, EmptyCommit
600 600 from rhodecode.lib.vcs.exceptions import RepositoryError
601 601 if not isinstance(repo, BaseRepository):
602 602 raise Exception('You must pass an Repository '
603 603 'object as first argument got %s', type(repo))
604 604
605 605 try:
606 606 commit = repo.get_commit(
607 607 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
608 608 except (RepositoryError, LookupError):
609 609 commit = EmptyCommit()
610 610 return commit
611 611
612 612
613 613 def datetime_to_time(dt):
614 614 if dt:
615 615 return time.mktime(dt.timetuple())
616 616
617 617
618 618 def time_to_datetime(tm):
619 619 if tm:
620 620 if isinstance(tm, basestring):
621 621 try:
622 622 tm = float(tm)
623 623 except ValueError:
624 624 return
625 625 return datetime.datetime.fromtimestamp(tm)
626 626
627 627
628 628 def time_to_utcdatetime(tm):
629 629 if tm:
630 630 if isinstance(tm, basestring):
631 631 try:
632 632 tm = float(tm)
633 633 except ValueError:
634 634 return
635 635 return datetime.datetime.utcfromtimestamp(tm)
636 636
637 637
638 638 MENTIONS_REGEX = re.compile(
639 639 # ^@ or @ without any special chars in front
640 640 r'(?:^@|[^a-zA-Z0-9\-\_\.]@)'
641 641 # main body starts with letter, then can be . - _
642 642 r'([a-zA-Z0-9]{1}[a-zA-Z0-9\-\_\.]+)',
643 643 re.VERBOSE | re.MULTILINE)
644 644
645 645
646 646 def extract_mentioned_users(s):
647 647 """
648 648 Returns unique usernames from given string s that have @mention
649 649
650 650 :param s: string to get mentions
651 651 """
652 652 usrs = set()
653 653 for username in MENTIONS_REGEX.findall(s):
654 654 usrs.add(username)
655 655
656 656 return sorted(list(usrs), key=lambda k: k.lower())
657 657
658 658
659 class UnsafeAttributeDict(dict):
659 class StrictAttributeDict(dict):
660 """
661 Strict Version of Attribute dict which raises an Attribute error when
662 requested attribute is not set
663 """
660 664 def __getattr__(self, attr):
661 665 try:
662 666 return self[attr]
663 667 except KeyError:
664 668 raise AttributeError('%s object has no attribute %s' % (self, attr))
665 669 __setattr__ = dict.__setitem__
666 670 __delattr__ = dict.__delitem__
667 671
668 672
669 673 class AttributeDict(dict):
670 674 def __getattr__(self, attr):
671 675 return self.get(attr, None)
672 676 __setattr__ = dict.__setitem__
673 677 __delattr__ = dict.__delitem__
674 678
675 679
676 680 def fix_PATH(os_=None):
677 681 """
678 682 Get current active python path, and append it to PATH variable to fix
679 683 issues of subprocess calls and different python versions
680 684 """
681 685 if os_ is None:
682 686 import os
683 687 else:
684 688 os = os_
685 689
686 690 cur_path = os.path.split(sys.executable)[0]
687 691 if not os.environ['PATH'].startswith(cur_path):
688 692 os.environ['PATH'] = '%s:%s' % (cur_path, os.environ['PATH'])
689 693
690 694
691 695 def obfuscate_url_pw(engine):
692 696 _url = engine or ''
693 697 try:
694 698 _url = sqlalchemy.engine.url.make_url(engine)
695 699 if _url.password:
696 700 _url.password = 'XXXXX'
697 701 except Exception:
698 702 pass
699 703 return unicode(_url)
700 704
701 705
702 706 def get_server_url(environ):
703 707 req = webob.Request(environ)
704 708 return req.host_url + req.script_name
705 709
706 710
707 711 def unique_id(hexlen=32):
708 712 alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz"
709 713 return suuid(truncate_to=hexlen, alphabet=alphabet)
710 714
711 715
712 716 def suuid(url=None, truncate_to=22, alphabet=None):
713 717 """
714 718 Generate and return a short URL safe UUID.
715 719
716 720 If the url parameter is provided, set the namespace to the provided
717 721 URL and generate a UUID.
718 722
719 723 :param url to get the uuid for
720 724 :truncate_to: truncate the basic 22 UUID to shorter version
721 725
722 726 The IDs won't be universally unique any longer, but the probability of
723 727 a collision will still be very low.
724 728 """
725 729 # Define our alphabet.
726 730 _ALPHABET = alphabet or "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
727 731
728 732 # If no URL is given, generate a random UUID.
729 733 if url is None:
730 734 unique_id = uuid.uuid4().int
731 735 else:
732 736 unique_id = uuid.uuid3(uuid.NAMESPACE_URL, url).int
733 737
734 738 alphabet_length = len(_ALPHABET)
735 739 output = []
736 740 while unique_id > 0:
737 741 digit = unique_id % alphabet_length
738 742 output.append(_ALPHABET[digit])
739 743 unique_id = int(unique_id / alphabet_length)
740 744 return "".join(output)[:truncate_to]
741 745
742 746
743 747 def get_current_rhodecode_user():
744 748 """
745 749 Gets rhodecode user from threadlocal tmpl_context variable if it's
746 750 defined, else returns None.
747 751 """
748 752 from pylons import tmpl_context as c
749 753 if hasattr(c, 'rhodecode_user'):
750 754 return c.rhodecode_user
751 755
752 756 return None
753 757
754 758
755 759 def action_logger_generic(action, namespace=''):
756 760 """
757 761 A generic logger for actions useful to the system overview, tries to find
758 762 an acting user for the context of the call otherwise reports unknown user
759 763
760 764 :param action: logging message eg 'comment 5 deleted'
761 765 :param type: string
762 766
763 767 :param namespace: namespace of the logging message eg. 'repo.comments'
764 768 :param type: string
765 769
766 770 """
767 771
768 772 logger_name = 'rhodecode.actions'
769 773
770 774 if namespace:
771 775 logger_name += '.' + namespace
772 776
773 777 log = logging.getLogger(logger_name)
774 778
775 779 # get a user if we can
776 780 user = get_current_rhodecode_user()
777 781
778 782 logfunc = log.info
779 783
780 784 if not user:
781 785 user = '<unknown user>'
782 786 logfunc = log.warning
783 787
784 788 logfunc('Logging action by {}: {}'.format(user, action))
785 789
786 790
787 791 def escape_split(text, sep=',', maxsplit=-1):
788 792 r"""
789 793 Allows for escaping of the separator: e.g. arg='foo\, bar'
790 794
791 795 It should be noted that the way bash et. al. do command line parsing, those
792 796 single quotes are required.
793 797 """
794 798 escaped_sep = r'\%s' % sep
795 799
796 800 if escaped_sep not in text:
797 801 return text.split(sep, maxsplit)
798 802
799 803 before, _mid, after = text.partition(escaped_sep)
800 804 startlist = before.split(sep, maxsplit) # a regular split is fine here
801 805 unfinished = startlist[-1]
802 806 startlist = startlist[:-1]
803 807
804 808 # recurse because there may be more escaped separators
805 809 endlist = escape_split(after, sep, maxsplit)
806 810
807 811 # finish building the escaped value. we use endlist[0] becaue the first
808 812 # part of the string sent in recursion is the rest of the escaped value.
809 813 unfinished += sep + endlist[0]
810 814
811 815 return startlist + [unfinished] + endlist[1:] # put together all the parts
812 816
813 817
814 818 class OptionalAttr(object):
815 819 """
816 820 Special Optional Option that defines other attribute. Example::
817 821
818 822 def test(apiuser, userid=Optional(OAttr('apiuser')):
819 823 user = Optional.extract(userid)
820 824 # calls
821 825
822 826 """
823 827
824 828 def __init__(self, attr_name):
825 829 self.attr_name = attr_name
826 830
827 831 def __repr__(self):
828 832 return '<OptionalAttr:%s>' % self.attr_name
829 833
830 834 def __call__(self):
831 835 return self
832 836
833 837
834 838 # alias
835 839 OAttr = OptionalAttr
836 840
837 841
838 842 class Optional(object):
839 843 """
840 844 Defines an optional parameter::
841 845
842 846 param = param.getval() if isinstance(param, Optional) else param
843 847 param = param() if isinstance(param, Optional) else param
844 848
845 849 is equivalent of::
846 850
847 851 param = Optional.extract(param)
848 852
849 853 """
850 854
851 855 def __init__(self, type_):
852 856 self.type_ = type_
853 857
854 858 def __repr__(self):
855 859 return '<Optional:%s>' % self.type_.__repr__()
856 860
857 861 def __call__(self):
858 862 return self.getval()
859 863
860 864 def getval(self):
861 865 """
862 866 returns value from this Optional instance
863 867 """
864 868 if isinstance(self.type_, OAttr):
865 869 # use params name
866 870 return self.type_.attr_name
867 871 return self.type_
868 872
869 873 @classmethod
870 874 def extract(cls, val):
871 875 """
872 876 Extracts value from Optional() instance
873 877
874 878 :param val:
875 879 :return: original value if it's not Optional instance else
876 880 value of instance
877 881 """
878 882 if isinstance(val, cls):
879 883 return val.getval()
880 884 return val
881 885
882 886
883 887 def get_routes_generator_for_server_url(server_url):
884 888 parsed_url = urlobject.URLObject(server_url)
885 889 netloc = safe_str(parsed_url.netloc)
886 890 script_name = safe_str(parsed_url.path)
887 891
888 892 if ':' in netloc:
889 893 server_name, server_port = netloc.split(':')
890 894 else:
891 895 server_name = netloc
892 896 server_port = (parsed_url.scheme == 'https' and '443' or '80')
893 897
894 898 environ = {
895 899 'REQUEST_METHOD': 'GET',
896 900 'PATH_INFO': '/',
897 901 'SERVER_NAME': server_name,
898 902 'SERVER_PORT': server_port,
899 903 'SCRIPT_NAME': script_name,
900 904 }
901 905 if parsed_url.scheme == 'https':
902 906 environ['HTTPS'] = 'on'
903 907 environ['wsgi.url_scheme'] = 'https'
904 908
905 909 return routes.util.URLGenerator(rhodecode.CONFIG['routes.map'], environ)
906 910
907 911
908 912 def glob2re(pat):
909 913 """
910 914 Translate a shell PATTERN to a regular expression.
911 915
912 916 There is no way to quote meta-characters.
913 917 """
914 918
915 919 i, n = 0, len(pat)
916 920 res = ''
917 921 while i < n:
918 922 c = pat[i]
919 923 i = i+1
920 924 if c == '*':
921 925 #res = res + '.*'
922 926 res = res + '[^/]*'
923 927 elif c == '?':
924 928 #res = res + '.'
925 929 res = res + '[^/]'
926 930 elif c == '[':
927 931 j = i
928 932 if j < n and pat[j] == '!':
929 933 j = j+1
930 934 if j < n and pat[j] == ']':
931 935 j = j+1
932 936 while j < n and pat[j] != ']':
933 937 j = j+1
934 938 if j >= n:
935 939 res = res + '\\['
936 940 else:
937 941 stuff = pat[i:j].replace('\\','\\\\')
938 942 i = j+1
939 943 if stuff[0] == '!':
940 944 stuff = '^' + stuff[1:]
941 945 elif stuff[0] == '^':
942 946 stuff = '\\' + stuff
943 947 res = '%s[%s]' % (res, stuff)
944 948 else:
945 949 res = res + re.escape(c)
946 950 return res + '\Z(?ms)'
@@ -1,640 +1,640 b''
1 1 // # Copyright (C) 2010-2016 RhodeCode GmbH
2 2 // #
3 3 // # This program is free software: you can redistribute it and/or modify
4 4 // # it under the terms of the GNU Affero General Public License, version 3
5 5 // # (only), as published by the Free Software Foundation.
6 6 // #
7 7 // # This program is distributed in the hope that it will be useful,
8 8 // # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 // # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 // # GNU General Public License for more details.
11 11 // #
12 12 // # You should have received a copy of the GNU Affero General Public License
13 13 // # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 // #
15 15 // # This program is dual-licensed. If you wish to learn more about the
16 16 // # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 // # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 var firefoxAnchorFix = function() {
20 20 // hack to make anchor links behave properly on firefox, in our inline
21 21 // comments generation when comments are injected firefox is misbehaving
22 22 // when jumping to anchor links
23 23 if (location.href.indexOf('#') > -1) {
24 24 location.href += '';
25 25 }
26 26 };
27 27
28 28 // returns a node from given html;
29 29 var fromHTML = function(html){
30 30 var _html = document.createElement('element');
31 31 _html.innerHTML = html;
32 32 return _html;
33 33 };
34 34
35 35 var tableTr = function(cls, body){
36 36 var _el = document.createElement('div');
37 37 var _body = $(body).attr('id');
38 38 var comment_id = fromHTML(body).children[0].id.split('comment-')[1];
39 39 var id = 'comment-tr-{0}'.format(comment_id);
40 40 var _html = ('<table><tbody><tr id="{0}" class="{1}">'+
41 41 '<td class="add-comment-line tooltip tooltip" title="Add Comment"><span class="add-comment-content"></span></td>'+
42 42 '<td></td>'+
43 43 '<td></td>'+
44 44 '<td></td>'+
45 45 '<td>{2}</td>'+
46 46 '</tr></tbody></table>').format(id, cls, body);
47 47 $(_el).html(_html);
48 48 return _el.children[0].children[0].children[0];
49 49 };
50 50
51 51 function bindDeleteCommentButtons() {
52 52 $('.delete-comment').one('click', function() {
53 53 var comment_id = $(this).data("comment-id");
54 54
55 55 if (comment_id){
56 56 deleteComment(comment_id);
57 57 }
58 58 });
59 59 }
60 60
61 61 var deleteComment = function(comment_id) {
62 62 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
63 63 var postData = {
64 64 '_method': 'delete',
65 65 'csrf_token': CSRF_TOKEN
66 66 };
67 67
68 68 var success = function(o) {
69 69 window.location.reload();
70 70 };
71 71 ajaxPOST(url, postData, success);
72 72 };
73 73
74 74
75 75 var bindToggleButtons = function() {
76 76 $('.comment-toggle').on('click', function() {
77 77 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
78 78 });
79 79 };
80 80
81 81 var linkifyComments = function(comments) {
82 82 /* TODO: dan: remove this - it should no longer needed */
83 83 for (var i = 0; i < comments.length; i++) {
84 84 var comment_id = $(comments[i]).data('comment-id');
85 85 var prev_comment_id = $(comments[i - 1]).data('comment-id');
86 86 var next_comment_id = $(comments[i + 1]).data('comment-id');
87 87
88 88 // place next/prev links
89 89 if (prev_comment_id) {
90 90 $('#prev_c_' + comment_id).show();
91 91 $('#prev_c_' + comment_id + " a.arrow_comment_link").attr(
92 92 'href', '#comment-' + prev_comment_id).removeClass('disabled');
93 93 }
94 94 if (next_comment_id) {
95 95 $('#next_c_' + comment_id).show();
96 96 $('#next_c_' + comment_id + " a.arrow_comment_link").attr(
97 97 'href', '#comment-' + next_comment_id).removeClass('disabled');
98 98 }
99 99 // place a first link to the total counter
100 100 if (i === 0) {
101 101 $('#inline-comments-counter').attr('href', '#comment-' + comment_id);
102 102 }
103 103 }
104 104
105 105 };
106 106
107 107
108 108 /* Comment form for main and inline comments */
109 109 var CommentForm = (function() {
110 110 "use strict";
111 111
112 112 function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions) {
113 113
114 114 this.withLineNo = function(selector) {
115 115 var lineNo = this.lineNo;
116 116 if (lineNo === undefined) {
117 117 return selector
118 118 } else {
119 119 return selector + '_' + lineNo;
120 120 }
121 121 };
122 122
123 123 this.commitId = commitId;
124 124 this.pullRequestId = pullRequestId;
125 125 this.lineNo = lineNo;
126 126 this.initAutocompleteActions = initAutocompleteActions;
127 127
128 128 this.previewButton = this.withLineNo('#preview-btn');
129 129 this.previewContainer = this.withLineNo('#preview-container');
130 130
131 131 this.previewBoxSelector = this.withLineNo('#preview-box');
132 132
133 133 this.editButton = this.withLineNo('#edit-btn');
134 134 this.editContainer = this.withLineNo('#edit-container');
135 135
136 136 this.cancelButton = this.withLineNo('#cancel-btn');
137 137
138 138 this.statusChange = '#change_status';
139 139 this.cmBox = this.withLineNo('#text');
140 140 this.cm = initCommentBoxCodeMirror(this.cmBox, this.initAutocompleteActions);
141 141
142 142 this.submitForm = formElement;
143 143 this.submitButton = $(this.submitForm).find('input[type="submit"]');
144 144 this.submitButtonText = this.submitButton.val();
145 145
146 146 this.previewUrl = pyroutes.url('changeset_comment_preview',
147 147 {'repo_name': templateContext.repo_name});
148 148
149 149 // based on commitId, or pullReuqestId decide where do we submit
150 150 // out data
151 151 if (this.commitId){
152 152 this.submitUrl = pyroutes.url('changeset_comment',
153 153 {'repo_name': templateContext.repo_name,
154 154 'revision': this.commitId});
155 155
156 156 } else if (this.pullRequestId) {
157 157 this.submitUrl = pyroutes.url('pullrequest_comment',
158 158 {'repo_name': templateContext.repo_name,
159 159 'pull_request_id': this.pullRequestId});
160 160
161 161 } else {
162 162 throw new Error(
163 163 'CommentForm requires pullRequestId, or commitId to be specified.')
164 164 }
165 165
166 166 this.getCmInstance = function(){
167 167 return this.cm
168 168 };
169 169
170 170 var self = this;
171 171
172 172 this.getCommentStatus = function() {
173 173 return $(this.submitForm).find(this.statusChange).val();
174 174 };
175 175
176 176 this.isAllowedToSubmit = function() {
177 177 return !$(this.submitButton).prop('disabled');
178 178 };
179 179
180 180 this.initStatusChangeSelector = function(){
181 181 var formatChangeStatus = function(state, escapeMarkup) {
182 182 var originalOption = state.element;
183 183 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
184 184 '<span>' + escapeMarkup(state.text) + '</span>';
185 185 };
186 186 var formatResult = function(result, container, query, escapeMarkup) {
187 187 return formatChangeStatus(result, escapeMarkup);
188 188 };
189 189
190 190 var formatSelection = function(data, container, escapeMarkup) {
191 191 return formatChangeStatus(data, escapeMarkup);
192 192 };
193 193
194 194 $(this.submitForm).find(this.statusChange).select2({
195 195 placeholder: _gettext('Status Review'),
196 196 formatResult: formatResult,
197 197 formatSelection: formatSelection,
198 198 containerCssClass: "drop-menu status_box_menu",
199 199 dropdownCssClass: "drop-menu-dropdown",
200 200 dropdownAutoWidth: true,
201 201 minimumResultsForSearch: -1
202 202 });
203 203 $(this.submitForm).find(this.statusChange).on('change', function() {
204 204 var status = self.getCommentStatus();
205 205 if (status && !self.lineNo) {
206 206 $(self.submitButton).prop('disabled', false);
207 207 }
208 208 //todo, fix this name
209 209 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
210 210 self.cm.setOption('placeholder', placeholderText);
211 211 })
212 212 };
213 213
214 214 // reset the comment form into it's original state
215 215 this.resetCommentFormState = function(content) {
216 216 content = content || '';
217 217
218 218 $(this.editContainer).show();
219 219 $(this.editButton).hide();
220 220
221 221 $(this.previewContainer).hide();
222 222 $(this.previewButton).show();
223 223
224 224 this.setActionButtonsDisabled(true);
225 225 self.cm.setValue(content);
226 226 self.cm.setOption("readOnly", false);
227 227 };
228 228
229 229 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
230 230 failHandler = failHandler || function() {};
231 231 var postData = toQueryString(postData);
232 232 var request = $.ajax({
233 233 url: url,
234 234 type: 'POST',
235 235 data: postData,
236 236 headers: {'X-PARTIAL-XHR': true}
237 237 })
238 238 .done(function(data) {
239 239 successHandler(data);
240 240 })
241 241 .fail(function(data, textStatus, errorThrown){
242 242 alert(
243 243 "Error while submitting comment.\n" +
244 244 "Error code {0} ({1}).".format(data.status, data.statusText));
245 245 failHandler()
246 246 });
247 247 return request;
248 248 };
249 249
250 250 // overwrite a submitHandler, we need to do it for inline comments
251 251 this.setHandleFormSubmit = function(callback) {
252 252 this.handleFormSubmit = callback;
253 253 };
254 254
255 255 // default handler for for submit for main comments
256 256 this.handleFormSubmit = function() {
257 257 var text = self.cm.getValue();
258 258 var status = self.getCommentStatus();
259 259
260 260 if (text === "" && !status) {
261 261 return;
262 262 }
263 263
264 264 var excludeCancelBtn = false;
265 265 var submitEvent = true;
266 266 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
267 267 self.cm.setOption("readOnly", true);
268 268 var postData = {
269 269 'text': text,
270 270 'changeset_status': status,
271 271 'csrf_token': CSRF_TOKEN
272 272 };
273 273
274 274 var submitSuccessCallback = function(o) {
275 275 if (status) {
276 276 location.reload(true);
277 277 } else {
278 278 $('#injected_page_comments').append(o.rendered_text);
279 279 self.resetCommentFormState();
280 280 bindDeleteCommentButtons();
281 281 timeagoActivate();
282 282 }
283 283 };
284 284 var submitFailCallback = function(){
285 285 self.resetCommentFormState(text)
286 286 };
287 287 self.submitAjaxPOST(
288 288 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
289 289 };
290 290
291 291 this.previewSuccessCallback = function(o) {
292 292 $(self.previewBoxSelector).html(o);
293 293 $(self.previewBoxSelector).removeClass('unloaded');
294 294
295 295 // swap buttons
296 296 $(self.previewButton).hide();
297 297 $(self.editButton).show();
298 298
299 299 // unlock buttons
300 300 self.setActionButtonsDisabled(false);
301 301 };
302 302
303 303 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
304 304 excludeCancelBtn = excludeCancelBtn || false;
305 305 submitEvent = submitEvent || false;
306 306
307 307 $(this.editButton).prop('disabled', state);
308 308 $(this.previewButton).prop('disabled', state);
309 309
310 310 if (!excludeCancelBtn) {
311 311 $(this.cancelButton).prop('disabled', state);
312 312 }
313 313
314 314 var submitState = state;
315 315 if (!submitEvent && this.getCommentStatus() && !this.lineNo) {
316 316 // if the value of commit review status is set, we allow
317 317 // submit button, but only on Main form, lineNo means inline
318 318 submitState = false
319 319 }
320 320 $(this.submitButton).prop('disabled', submitState);
321 321 if (submitEvent) {
322 322 $(this.submitButton).val(_gettext('Submitting...'));
323 323 } else {
324 324 $(this.submitButton).val(this.submitButtonText);
325 325 }
326 326
327 327 };
328 328
329 329 // lock preview/edit/submit buttons on load, but exclude cancel button
330 330 var excludeCancelBtn = true;
331 331 this.setActionButtonsDisabled(true, excludeCancelBtn);
332 332
333 333 // anonymous users don't have access to initialized CM instance
334 334 if (this.cm !== undefined){
335 335 this.cm.on('change', function(cMirror) {
336 336 if (cMirror.getValue() === "") {
337 337 self.setActionButtonsDisabled(true, excludeCancelBtn)
338 338 } else {
339 339 self.setActionButtonsDisabled(false, excludeCancelBtn)
340 340 }
341 341 });
342 342 }
343 343
344 344 $(this.editButton).on('click', function(e) {
345 345 e.preventDefault();
346 346
347 347 $(self.previewButton).show();
348 348 $(self.previewContainer).hide();
349 349 $(self.editButton).hide();
350 350 $(self.editContainer).show();
351 351
352 352 });
353 353
354 354 $(this.previewButton).on('click', function(e) {
355 355 e.preventDefault();
356 356 var text = self.cm.getValue();
357 357
358 358 if (text === "") {
359 359 return;
360 360 }
361 361
362 362 var postData = {
363 363 'text': text,
364 364 'renderer': DEFAULT_RENDERER,
365 365 'csrf_token': CSRF_TOKEN
366 366 };
367 367
368 368 // lock ALL buttons on preview
369 369 self.setActionButtonsDisabled(true);
370 370
371 371 $(self.previewBoxSelector).addClass('unloaded');
372 372 $(self.previewBoxSelector).html(_gettext('Loading ...'));
373 373 $(self.editContainer).hide();
374 374 $(self.previewContainer).show();
375 375
376 376 // by default we reset state of comment preserving the text
377 377 var previewFailCallback = function(){
378 378 self.resetCommentFormState(text)
379 379 };
380 380 self.submitAjaxPOST(
381 381 self.previewUrl, postData, self.previewSuccessCallback, previewFailCallback);
382 382
383 383 });
384 384
385 385 $(this.submitForm).submit(function(e) {
386 386 e.preventDefault();
387 387 var allowedToSubmit = self.isAllowedToSubmit();
388 388 if (!allowedToSubmit){
389 389 return false;
390 390 }
391 391 self.handleFormSubmit();
392 392 });
393 393
394 394 }
395 395
396 396 return CommentForm;
397 397 })();
398 398
399 399 var CommentsController = function() { /* comments controller */
400 400 var self = this;
401 401
402 402 this.cancelComment = function(node) {
403 403 var $node = $(node);
404 404 var $td = $node.closest('td');
405 405 $node.closest('.comment-inline-form').removeClass('comment-inline-form-open');
406 406 return false;
407 407 };
408 408
409 409 this.getLineNumber = function(node) {
410 410 var $node = $(node);
411 411 return $node.closest('td').attr('data-line-number');
412 412 };
413 413
414 414 this.scrollToComment = function(node, offset) {
415 415 if (!node) {
416 416 node = $('.comment-selected');
417 417 if (!node.length) {
418 418 node = $('comment-current')
419 419 }
420 420 }
421 421 $comment = $(node).closest('.comment-current');
422 422 $comments = $('.comment-current');
423 423
424 424 $('.comment-selected').removeClass('comment-selected');
425 425
426 426 var nextIdx = $('.comment-current').index($comment) + offset;
427 427 if (nextIdx >= $comments.length) {
428 428 nextIdx = 0;
429 429 }
430 430 var $next = $('.comment-current').eq(nextIdx);
431 431 var $cb = $next.closest('.cb');
432 432 $cb.removeClass('cb-collapsed');
433 433
434 434 var $filediffCollapseState = $cb.closest('.filediff').prev();
435 435 $filediffCollapseState.prop('checked', false);
436 436 $next.addClass('comment-selected');
437 437 scrollToElement($next);
438 438 return false;
439 439 };
440 440
441 441 this.nextComment = function(node) {
442 442 return self.scrollToComment(node, 1);
443 443 };
444 444
445 445 this.prevComment = function(node) {
446 446 return self.scrollToComment(node, -1);
447 447 };
448 448
449 449 this.deleteComment = function(node) {
450 450 if (!confirm(_gettext('Delete this comment?'))) {
451 451 return false;
452 452 }
453 453 var $node = $(node);
454 454 var $td = $node.closest('td');
455 455 var $comment = $node.closest('.comment');
456 456 var comment_id = $comment.attr('data-comment-id');
457 457 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
458 458 var postData = {
459 459 '_method': 'delete',
460 460 'csrf_token': CSRF_TOKEN
461 461 };
462 462
463 463 $comment.addClass('comment-deleting');
464 464 $comment.hide('fast');
465 465
466 466 var success = function(response) {
467 467 $comment.remove();
468 468 return false;
469 469 };
470 470 var failure = function(data, textStatus, xhr) {
471 471 alert("error processing request: " + textStatus);
472 472 $comment.show('fast');
473 473 $comment.removeClass('comment-deleting');
474 474 return false;
475 475 };
476 476 ajaxPOST(url, postData, success, failure);
477 477 };
478 478
479 479 this.toggleWideMode = function (node) {
480 480 if ($('#content').hasClass('wrapper')) {
481 481 $('#content').removeClass("wrapper");
482 482 $('#content').addClass("wide-mode-wrapper");
483 483 $(node).addClass('btn-success');
484 484 } else {
485 485 $('#content').removeClass("wide-mode-wrapper");
486 486 $('#content').addClass("wrapper");
487 487 $(node).removeClass('btn-success');
488 488 }
489 489 return false;
490 490 };
491 491
492 492 this.toggleComments = function(node, show) {
493 493 var $filediff = $(node).closest('.filediff');
494 494 if (show === true) {
495 495 $filediff.removeClass('hide-comments');
496 496 } else if (show === false) {
497 497 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
498 498 $filediff.addClass('hide-comments');
499 499 } else {
500 500 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
501 501 $filediff.toggleClass('hide-comments');
502 502 }
503 503 return false;
504 504 };
505 505
506 506 this.toggleLineComments = function(node) {
507 507 self.toggleComments(node, true);
508 508 var $node = $(node);
509 509 $node.closest('tr').toggleClass('hide-line-comments');
510 510 };
511 511
512 512 this.createComment = function(node) {
513 513 var $node = $(node);
514 514 var $td = $node.closest('td');
515 515 var $form = $td.find('.comment-inline-form');
516 516
517 517 if (!$form.length) {
518 518 var tmpl = $('#cb-comment-inline-form-template').html();
519 519 var $filediff = $node.closest('.filediff');
520 520 $filediff.removeClass('hide-comments');
521 521 var f_path = $filediff.attr('data-f-path');
522 522 var lineno = self.getLineNumber(node);
523 523 tmpl = tmpl.format(f_path, lineno);
524 524 $form = $(tmpl);
525 525
526 526 var $comments = $td.find('.inline-comments');
527 527 if (!$comments.length) {
528 528 $comments = $(
529 529 $('#cb-comments-inline-container-template').html());
530 530 $td.append($comments);
531 531 }
532 532
533 533 $td.find('.cb-comment-add-button').before($form);
534 534
535 535 var pullRequestId = templateContext.pull_request_data.pull_request_id;
536 536 var commitId = templateContext.commit_data.commit_id;
537 537 var _form = $form[0];
538 538 var commentForm = new CommentForm(_form, commitId, pullRequestId, lineno, false);
539 539 var cm = commentForm.getCmInstance();
540 540
541 541 // set a CUSTOM submit handler for inline comments.
542 542 commentForm.setHandleFormSubmit(function(o) {
543 543 var text = commentForm.cm.getValue();
544 544
545 545 if (text === "") {
546 546 return;
547 547 }
548 548
549 549 if (lineno === undefined) {
550 550 alert('missing line !');
551 551 return;
552 552 }
553 553 if (f_path === undefined) {
554 554 alert('missing file path !');
555 555 return;
556 556 }
557 557
558 558 var excludeCancelBtn = false;
559 559 var submitEvent = true;
560 560 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
561 561 commentForm.cm.setOption("readOnly", true);
562 562 var postData = {
563 563 'text': text,
564 564 'f_path': f_path,
565 565 'line': lineno,
566 566 'csrf_token': CSRF_TOKEN
567 567 };
568 568 var submitSuccessCallback = function(json_data) {
569 569 $form.remove();
570 570 try {
571 571 var html = json_data.rendered_text;
572 572 var lineno = json_data.line_no;
573 573 var target_id = json_data.target_id;
574 574
575 575 $comments.find('.cb-comment-add-button').before(html);
576 576
577 577 } catch (e) {
578 578 console.error(e);
579 579 }
580 580
581 581 // re trigger the linkification of next/prev navigation
582 582 linkifyComments($('.inline-comment-injected'));
583 583 timeagoActivate();
584 584 bindDeleteCommentButtons();
585 585 commentForm.setActionButtonsDisabled(false);
586 586
587 587 };
588 588 var submitFailCallback = function(){
589 589 commentForm.resetCommentFormState(text)
590 590 };
591 591 commentForm.submitAjaxPOST(
592 592 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
593 593 });
594 594
595 595 setTimeout(function() {
596 596 // callbacks
597 597 if (cm !== undefined) {
598 598 cm.focus();
599 599 }
600 600 }, 10);
601 601
602 602 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
603 603 form: _form,
604 604 parent: $td[0],
605 605 lineno: lineno,
606 606 f_path: f_path}
607 607 );
608 608 }
609 609
610 610 $form.addClass('comment-inline-form-open');
611 611 };
612 612
613 613 this.renderInlineComments = function(file_comments) {
614 614 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
615 615
616 616 for (var i = 0; i < file_comments.length; i++) {
617 617 var box = file_comments[i];
618 618
619 619 var target_id = $(box).attr('target_id');
620 620
621 621 // actually comments with line numbers
622 622 var comments = box.children;
623 623
624 624 for (var j = 0; j < comments.length; j++) {
625 625 var data = {
626 626 'rendered_text': comments[j].outerHTML,
627 627 'line_no': $(comments[j]).attr('line'),
628 628 'target_id': target_id
629 629 };
630 630 }
631 631 }
632 632
633 633 // since order of injection is random, we're now re-iterating
634 634 // from correct order and filling in links
635 635 linkifyComments($('.inline-comment-injected'));
636 636 bindDeleteCommentButtons();
637 637 firefoxAnchorFix();
638 638 };
639 639
640 }; No newline at end of file
640 };
@@ -1,577 +1,577 b''
1 1 <%def name="diff_line_anchor(filename, line, type)"><%
2 2 return '%s_%s_%i' % (h.safeid(filename), type, line)
3 3 %></%def>
4 4
5 5 <%def name="action_class(action)"><%
6 6 return {
7 7 '-': 'cb-deletion',
8 8 '+': 'cb-addition',
9 9 ' ': 'cb-context',
10 10 }.get(action, 'cb-empty')
11 11 %></%def>
12 12
13 13 <%def name="op_class(op_id)"><%
14 14 return {
15 15 DEL_FILENODE: 'deletion', # file deleted
16 16 BIN_FILENODE: 'warning' # binary diff hidden
17 17 }.get(op_id, 'addition')
18 18 %></%def>
19 19
20 20 <%def name="link_for(**kw)"><%
21 21 new_args = request.GET.mixed()
22 22 new_args.update(kw)
23 23 return h.url('', **new_args)
24 24 %></%def>
25 25
26 26 <%def name="render_diffset(diffset, commit=None,
27 27
28 28 # collapse all file diff entries when there are more than this amount of files in the diff
29 29 collapse_when_files_over=20,
30 30
31 31 # collapse lines in the diff when more than this amount of lines changed in the file diff
32 32 lines_changed_limit=500,
33 33
34 34 # add a ruler at to the output
35 35 ruler_at_chars=0,
36 36
37 37 # show inline comments
38 38 use_comments=False,
39 39
40 40 # disable new comments
41 41 disable_new_comments=False,
42 42
43 43 )">
44 44
45 45 %if use_comments:
46 46 <div id="cb-comments-inline-container-template" class="js-template">
47 47 ${inline_comments_container([])}
48 48 </div>
49 49 <div class="js-template" id="cb-comment-inline-form-template">
50 50 <div class="comment-inline-form ac">
51 51 %if c.rhodecode_user.username != h.DEFAULT_USER:
52 52 ${h.form('#', method='get')}
53 53 <div id="edit-container_{1}" class="clearfix">
54 54 <div class="comment-title pull-left">
55 55 ${_('Create a comment on line {1}.')}
56 56 </div>
57 57 <div class="comment-help pull-right">
58 58 ${(_('Comments parsed using %s syntax with %s support.') % (
59 59 ('<a href="%s">%s</a>' % (h.url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
60 60 ('<span class="tooltip" title="%s">@mention</span>' % _('Use @username inside this text to send notification to this RhodeCode user'))
61 61 )
62 62 )|n
63 63 }
64 64 </div>
65 65 <div style="clear: both"></div>
66 66 <textarea id="text_{1}" name="text" class="comment-block-ta ac-input"></textarea>
67 67 </div>
68 68 <div id="preview-container_{1}" class="clearfix" style="display: none;">
69 69 <div class="comment-help">
70 70 ${_('Comment preview')}
71 71 </div>
72 72 <div id="preview-box_{1}" class="preview-box"></div>
73 73 </div>
74 74 <div class="comment-footer">
75 75 <div class="action-buttons">
76 76 <input type="hidden" name="f_path" value="{0}">
77 77 <input type="hidden" name="line" value="{1}">
78 78 <button id="preview-btn_{1}" class="btn btn-secondary">${_('Preview')}</button>
79 79 <button id="edit-btn_{1}" class="btn btn-secondary" style="display: none;">${_('Edit')}</button>
80 80 ${h.submit('save', _('Comment'), class_='btn btn-success save-inline-form')}
81 81 </div>
82 82 <div class="comment-button">
83 83 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
84 84 ${_('Cancel')}
85 85 </button>
86 86 </div>
87 87 ${h.end_form()}
88 88 </div>
89 89 %else:
90 90 ${h.form('', class_='inline-form comment-form-login', method='get')}
91 91 <div class="pull-left">
92 92 <div class="comment-help pull-right">
93 93 ${_('You need to be logged in to comment.')} <a href="${h.route_path('login', _query={'came_from': h.url.current()})}">${_('Login now')}</a>
94 94 </div>
95 95 </div>
96 96 <div class="comment-button pull-right">
97 97 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
98 98 ${_('Cancel')}
99 99 </button>
100 100 </div>
101 101 <div class="clearfix"></div>
102 102 ${h.end_form()}
103 103 %endif
104 104 </div>
105 105 </div>
106 106
107 107 %endif
108 108 <%
109 109 collapse_all = len(diffset.files) > collapse_when_files_over
110 110 %>
111 111
112 112 %if c.diffmode == 'sideside':
113 113 <style>
114 114 .wrapper {
115 115 max-width: 1600px !important;
116 116 }
117 117 </style>
118 118 %endif
119 119 %if ruler_at_chars:
120 120 <style>
121 121 .diff table.cb .cb-content:after {
122 122 content: "";
123 123 border-left: 1px solid blue;
124 124 position: absolute;
125 125 top: 0;
126 126 height: 18px;
127 127 opacity: .2;
128 128 z-index: 10;
129 129 ## +5 to account for diff action (+/-)
130 130 left: ${ruler_at_chars + 5}ch;
131 131 </style>
132 132 %endif
133 133 <div class="diffset ${disable_new_comments and 'diffset-comments-disabled'}">
134 134 <div class="diffset-heading ${diffset.limited_diff and 'diffset-heading-warning' or ''}">
135 135 %if commit:
136 136 <div class="pull-right">
137 137 <a class="btn tooltip" title="${_('Browse Files at revision {}').format(commit.raw_id)}" href="${h.url('files_home',repo_name=diffset.repo_name, revision=commit.raw_id, f_path='')}">
138 138 ${_('Browse Files')}
139 139 </a>
140 140 </div>
141 141 %endif
142 142 <h2 class="clearinner">
143 143 %if commit:
144 144 <a class="tooltip revision" title="${h.tooltip(commit.message)}" href="${h.url('changeset_home',repo_name=c.repo_name,revision=commit.raw_id)}">${'r%s:%s' % (commit.revision,h.short_id(commit.raw_id))}</a> -
145 145 ${h.age_component(commit.date)} -
146 146 %endif
147 147 %if diffset.limited_diff:
148 148 ${_('The requested commit is too big and content was truncated.')}
149 149
150 150 ${ungettext('%(num)s file changed.', '%(num)s files changed.', diffset.changed_files) % {'num': diffset.changed_files}}
151 151 <a href="${link_for(fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
152 152 %else:
153 153 ${ungettext('%(num)s file changed: %(linesadd)s inserted, ''%(linesdel)s deleted',
154 154 '%(num)s files changed: %(linesadd)s inserted, %(linesdel)s deleted', diffset.changed_files) % {'num': diffset.changed_files, 'linesadd': diffset.lines_added, 'linesdel': diffset.lines_deleted}}
155 155 %endif
156 156 </h2>
157 157 </div>
158 158
159 159 %if not diffset.files:
160 160 <p class="empty_data">${_('No files')}</p>
161 161 %endif
162 162
163 163 <div class="filediffs">
164 164 %for i, filediff in enumerate(diffset.files):
165 165 <%
166 166 lines_changed = filediff['patch']['stats']['added'] + filediff['patch']['stats']['deleted']
167 167 over_lines_changed_limit = lines_changed > lines_changed_limit
168 168 %>
169 169 <input ${collapse_all and 'checked' or ''} class="filediff-collapse-state" id="filediff-collapse-${id(filediff)}" type="checkbox">
170 170 <div
171 171 class="filediff"
172 172 data-f-path="${filediff['patch']['filename']}"
173 173 id="a_${h.FID('', filediff['patch']['filename'])}">
174 174 <label for="filediff-collapse-${id(filediff)}" class="filediff-heading">
175 175 <div class="filediff-collapse-indicator"></div>
176 176 ${diff_ops(filediff)}
177 177 </label>
178 178 ${diff_menu(filediff, use_comments=use_comments)}
179 179 <table class="cb cb-diff-${c.diffmode} code-highlight ${over_lines_changed_limit and 'cb-collapsed' or ''}">
180 180 %if not filediff.hunks:
181 181 %for op_id, op_text in filediff['patch']['stats']['ops'].items():
182 182 <tr>
183 183 <td class="cb-text cb-${op_class(op_id)}" ${c.diffmode == 'unified' and 'colspan=3' or 'colspan=4'}>
184 184 %if op_id == DEL_FILENODE:
185 185 ${_('File was deleted')}
186 186 %elif op_id == BIN_FILENODE:
187 187 ${_('Binary file hidden')}
188 188 %else:
189 189 ${op_text}
190 190 %endif
191 191 </td>
192 192 </tr>
193 193 %endfor
194 194 %endif
195 195 %if over_lines_changed_limit:
196 196 <tr class="cb-warning cb-collapser">
197 197 <td class="cb-text" ${c.diffmode == 'unified' and 'colspan=4' or 'colspan=6'}>
198 198 ${_('This diff has been collapsed as it changes many lines, (%i lines changed)' % lines_changed)}
199 199 <a href="#" class="cb-expand"
200 200 onclick="$(this).closest('table').removeClass('cb-collapsed'); return false;">${_('Show them')}
201 201 </a>
202 202 <a href="#" class="cb-collapse"
203 203 onclick="$(this).closest('table').addClass('cb-collapsed'); return false;">${_('Hide them')}
204 204 </a>
205 205 </td>
206 206 </tr>
207 207 %endif
208 208 %if filediff.patch['is_limited_diff']:
209 209 <tr class="cb-warning cb-collapser">
210 210 <td class="cb-text" ${c.diffmode == 'unified' and 'colspan=4' or 'colspan=6'}>
211 211 ${_('The requested commit is too big and content was truncated.')} <a href="${link_for(fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
212 212 </td>
213 213 </tr>
214 214 %endif
215 215 %for hunk in filediff.hunks:
216 216 <tr class="cb-hunk">
217 217 <td ${c.diffmode == 'unified' and 'colspan=3' or ''}>
218 218 ## TODO: dan: add ajax loading of more context here
219 219 ## <a href="#">
220 220 <i class="icon-more"></i>
221 221 ## </a>
222 222 </td>
223 223 <td ${c.diffmode == 'sideside' and 'colspan=5' or ''}>
224 224 @@
225 225 -${hunk.source_start},${hunk.source_length}
226 226 +${hunk.target_start},${hunk.target_length}
227 227 ${hunk.section_header}
228 228 </td>
229 229 </tr>
230 230 %if c.diffmode == 'unified':
231 231 ${render_hunk_lines_unified(hunk, use_comments=use_comments)}
232 232 %elif c.diffmode == 'sideside':
233 233 ${render_hunk_lines_sideside(hunk, use_comments=use_comments)}
234 234 %else:
235 235 <tr class="cb-line">
236 236 <td>unknown diff mode</td>
237 237 </tr>
238 238 %endif
239 239 %endfor
240 240 </table>
241 241 </div>
242 242 %endfor
243 243 </div>
244 244 </div>
245 245 </%def>
246 246
247 247 <%def name="diff_ops(filediff)">
248 248 <%
249 249 stats = filediff['patch']['stats']
250 250 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
251 251 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE
252 252 %>
253 253 <span class="pill">
254 254 %if filediff.source_file_path and filediff.target_file_path:
255 255 %if filediff.source_file_path != filediff.target_file_path: # file was renamed
256 256 <strong>${filediff.target_file_path}</strong> β¬… <del>${filediff.source_file_path}</del>
257 257 %else:
258 258 ## file was modified
259 259 <strong>${filediff.source_file_path}</strong>
260 260 %endif
261 261 %else:
262 262 %if filediff.source_file_path:
263 263 ## file was deleted
264 264 <strong>${filediff.source_file_path}</strong>
265 265 %else:
266 266 ## file was added
267 267 <strong>${filediff.target_file_path}</strong>
268 268 %endif
269 269 %endif
270 270 </span>
271 271 <span class="pill-group" style="float: left">
272 272 %if filediff.patch['is_limited_diff']:
273 273 <span class="pill tooltip" op="limited" title="The stats for this diff are not complete">limited diff</span>
274 274 %endif
275 275 %if RENAMED_FILENODE in stats['ops']:
276 276 <span class="pill" op="renamed">renamed</span>
277 277 %endif
278 278
279 279 %if NEW_FILENODE in stats['ops']:
280 280 <span class="pill" op="created">created</span>
281 281 %if filediff['target_mode'].startswith('120'):
282 282 <span class="pill" op="symlink">symlink</span>
283 283 %else:
284 284 <span class="pill" op="mode">${nice_mode(filediff['target_mode'])}</span>
285 285 %endif
286 286 %endif
287 287
288 288 %if DEL_FILENODE in stats['ops']:
289 289 <span class="pill" op="removed">removed</span>
290 290 %endif
291 291
292 292 %if CHMOD_FILENODE in stats['ops']:
293 293 <span class="pill" op="mode">
294 294 ${nice_mode(filediff['source_mode'])} ➑ ${nice_mode(filediff['target_mode'])}
295 295 </span>
296 296 %endif
297 297 </span>
298 298
299 299 <a class="pill filediff-anchor" href="#a_${h.FID('', filediff.patch['filename'])}">ΒΆ</a>
300 300
301 301 <span class="pill-group" style="float: right">
302 302 %if BIN_FILENODE in stats['ops']:
303 303 <span class="pill" op="binary">binary</span>
304 304 %if MOD_FILENODE in stats['ops']:
305 305 <span class="pill" op="modified">modified</span>
306 306 %endif
307 307 %endif
308 308 %if stats['added']:
309 309 <span class="pill" op="added">+${stats['added']}</span>
310 310 %endif
311 311 %if stats['deleted']:
312 312 <span class="pill" op="deleted">-${stats['deleted']}</span>
313 313 %endif
314 314 </span>
315 315
316 316 </%def>
317 317
318 318 <%def name="nice_mode(filemode)">
319 319 ${filemode.startswith('100') and filemode[3:] or filemode}
320 320 </%def>
321 321
322 322 <%def name="diff_menu(filediff, use_comments=False)">
323 323 <div class="filediff-menu">
324 324 %if filediff.diffset.source_ref:
325 325 %if filediff.patch['operation'] in ['D', 'M']:
326 326 <a
327 327 class="tooltip"
328 328 href="${h.url('files_home',repo_name=filediff.diffset.repo_name,f_path=filediff.source_file_path,revision=filediff.diffset.source_ref)}"
329 329 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
330 330 >
331 331 ${_('Show file before')}
332 332 </a>
333 333 %else:
334 334 <span
335 335 class="tooltip"
336 336 title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
337 337 >
338 338 ${_('Show file before')}
339 339 </span>
340 340 %endif
341 341 %if filediff.patch['operation'] in ['A', 'M']:
342 342 <a
343 343 class="tooltip"
344 href="${h.url('files_home',repo_name=filediff.diffset.repo_name,f_path=filediff.target_file_path,revision=filediff.diffset.target_ref)}"
344 href="${h.url('files_home',repo_name=filediff.diffset.source_repo_name,f_path=filediff.target_file_path,revision=filediff.diffset.target_ref)}"
345 345 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
346 346 >
347 347 ${_('Show file after')}
348 348 </a>
349 349 %else:
350 350 <span
351 351 class="tooltip"
352 352 title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
353 353 >
354 354 ${_('Show file after')}
355 355 </span>
356 356 %endif
357 357 <a
358 358 class="tooltip"
359 359 title="${h.tooltip(_('Raw diff'))}"
360 360 href="${h.url('files_diff_home',repo_name=filediff.diffset.repo_name,f_path=filediff.target_file_path,diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='raw')}"
361 361 >
362 362 ${_('Raw diff')}
363 363 </a>
364 364 <a
365 365 class="tooltip"
366 366 title="${h.tooltip(_('Download diff'))}"
367 367 href="${h.url('files_diff_home',repo_name=filediff.diffset.repo_name,f_path=filediff.target_file_path,diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='download')}"
368 368 >
369 369 ${_('Download diff')}
370 370 </a>
371 371
372 372 ## TODO: dan: refactor ignorews_url and context_url into the diff renderer same as diffmode=unified/sideside. Also use ajax to load more context (by clicking hunks)
373 373 %if hasattr(c, 'ignorews_url'):
374 374 ${c.ignorews_url(request.GET, h.FID('', filediff['patch']['filename']))}
375 375 %endif
376 376 %if hasattr(c, 'context_url'):
377 377 ${c.context_url(request.GET, h.FID('', filediff['patch']['filename']))}
378 378 %endif
379 379
380 380
381 381 %if use_comments:
382 382 <a href="#" onclick="return Rhodecode.comments.toggleComments(this);">
383 383 <span class="show-comment-button">${_('Show comments')}</span><span class="hide-comment-button">${_('Hide comments')}</span>
384 384 </a>
385 385 %endif
386 386 %endif
387 387 </div>
388 388 </%def>
389 389
390 390
391 391 <%namespace name="commentblock" file="/changeset/changeset_file_comment.html"/>
392 392 <%def name="inline_comments_container(comments)">
393 393 <div class="inline-comments">
394 394 %for comment in comments:
395 395 ${commentblock.comment_block(comment, inline=True)}
396 396 %endfor
397 397
398 398 <span onclick="return Rhodecode.comments.createComment(this)"
399 399 class="btn btn-secondary cb-comment-add-button ${'comment-outdated' if comments and comments[-1].outdated else ''}"
400 400 style="${'display: none;' if comments and comments[-1].outdated else ''}">
401 401 ${_('Add another comment')}
402 402 </span>
403 403
404 404 </div>
405 405 </%def>
406 406
407 407
408 408 <%def name="render_hunk_lines_sideside(hunk, use_comments=False)">
409 409 %for i, line in enumerate(hunk.sideside):
410 410 <%
411 411 old_line_anchor, new_line_anchor = None, None
412 412 if line.original.lineno:
413 413 old_line_anchor = diff_line_anchor(hunk.filediff.source_file_path, line.original.lineno, 'o')
414 414 if line.modified.lineno:
415 415 new_line_anchor = diff_line_anchor(hunk.filediff.target_file_path, line.modified.lineno, 'n')
416 416 %>
417 417 <tr class="cb-line">
418 418 <td class="cb-data ${action_class(line.original.action)}"
419 419 data-line-number="${line.original.lineno}"
420 420 >
421 421 <div>
422 422 %if line.original.comments:
423 423 <i class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
424 424 %endif
425 425 </div>
426 426 </td>
427 427 <td class="cb-lineno ${action_class(line.original.action)}"
428 428 data-line-number="${line.original.lineno}"
429 429 %if old_line_anchor:
430 430 id="${old_line_anchor}"
431 431 %endif
432 432 >
433 433 %if line.original.lineno:
434 434 <a name="${old_line_anchor}" href="#${old_line_anchor}">${line.original.lineno}</a>
435 435 %endif
436 436 </td>
437 437 <td class="cb-content ${action_class(line.original.action)}"
438 438 data-line-number="o${line.original.lineno}"
439 439 >
440 440 %if use_comments and line.original.lineno:
441 441 ${render_add_comment_button()}
442 442 %endif
443 443 <span class="cb-code">${line.original.action} ${line.original.content or '' | n}</span>
444 444 %if use_comments and line.original.lineno and line.original.comments:
445 445 ${inline_comments_container(line.original.comments)}
446 446 %endif
447 447 </td>
448 448 <td class="cb-data ${action_class(line.modified.action)}"
449 449 data-line-number="${line.modified.lineno}"
450 450 >
451 451 <div>
452 452 %if line.modified.comments:
453 453 <i class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
454 454 %endif
455 455 </div>
456 456 </td>
457 457 <td class="cb-lineno ${action_class(line.modified.action)}"
458 458 data-line-number="${line.modified.lineno}"
459 459 %if new_line_anchor:
460 460 id="${new_line_anchor}"
461 461 %endif
462 462 >
463 463 %if line.modified.lineno:
464 464 <a name="${new_line_anchor}" href="#${new_line_anchor}">${line.modified.lineno}</a>
465 465 %endif
466 466 </td>
467 467 <td class="cb-content ${action_class(line.modified.action)}"
468 468 data-line-number="n${line.modified.lineno}"
469 469 >
470 470 %if use_comments and line.modified.lineno:
471 471 ${render_add_comment_button()}
472 472 %endif
473 473 <span class="cb-code">${line.modified.action} ${line.modified.content or '' | n}</span>
474 474 %if use_comments and line.modified.lineno and line.modified.comments:
475 475 ${inline_comments_container(line.modified.comments)}
476 476 %endif
477 477 </td>
478 478 </tr>
479 479 %endfor
480 480 </%def>
481 481
482 482
483 483 <%def name="render_hunk_lines_unified(hunk, use_comments=False)">
484 484 %for old_line_no, new_line_no, action, content, comments in hunk.unified:
485 485 <%
486 486 old_line_anchor, new_line_anchor = None, None
487 487 if old_line_no:
488 488 old_line_anchor = diff_line_anchor(hunk.filediff.source_file_path, old_line_no, 'o')
489 489 if new_line_no:
490 490 new_line_anchor = diff_line_anchor(hunk.filediff.target_file_path, new_line_no, 'n')
491 491 %>
492 492 <tr class="cb-line">
493 493 <td class="cb-data ${action_class(action)}">
494 494 <div>
495 495 %if comments:
496 496 <i class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
497 497 %endif
498 498 </div>
499 499 </td>
500 500 <td class="cb-lineno ${action_class(action)}"
501 501 data-line-number="${old_line_no}"
502 502 %if old_line_anchor:
503 503 id="${old_line_anchor}"
504 504 %endif
505 505 >
506 506 %if old_line_anchor:
507 507 <a name="${old_line_anchor}" href="#${old_line_anchor}">${old_line_no}</a>
508 508 %endif
509 509 </td>
510 510 <td class="cb-lineno ${action_class(action)}"
511 511 data-line-number="${new_line_no}"
512 512 %if new_line_anchor:
513 513 id="${new_line_anchor}"
514 514 %endif
515 515 >
516 516 %if new_line_anchor:
517 517 <a name="${new_line_anchor}" href="#${new_line_anchor}">${new_line_no}</a>
518 518 %endif
519 519 </td>
520 520 <td class="cb-content ${action_class(action)}"
521 521 data-line-number="${new_line_no and 'n' or 'o'}${new_line_no or old_line_no}"
522 522 >
523 523 %if use_comments:
524 524 ${render_add_comment_button()}
525 525 %endif
526 526 <span class="cb-code">${action} ${content or '' | n}</span>
527 527 %if use_comments and comments:
528 528 ${inline_comments_container(comments)}
529 529 %endif
530 530 </td>
531 531 </tr>
532 532 %endfor
533 533 </%def>
534 534
535 535 <%def name="render_add_comment_button()">
536 536 <button
537 537 class="btn btn-small btn-primary cb-comment-box-opener"
538 538 onclick="return Rhodecode.comments.createComment(this)"
539 539 ><span>+</span></button>
540 540 </%def>
541 541
542 542 <%def name="render_diffset_menu()">
543 543
544 544 <div class="diffset-menu clearinner">
545 545 <div class="pull-right">
546 546 <div class="btn-group">
547 547 <a
548 548 class="btn ${c.diffmode == 'sideside' and 'btn-primary'} tooltip"
549 549 title="${_('View side by side')}"
550 550 href="${h.url_replace(diffmode='sideside')}">
551 551 <span>${_('Side by Side')}</span>
552 552 </a>
553 553 <a
554 554 class="btn ${c.diffmode == 'unified' and 'btn-primary'} tooltip"
555 555 title="${_('View unified')}" href="${h.url_replace(diffmode='unified')}">
556 556 <span>${_('Unified')}</span>
557 557 </a>
558 558 </div>
559 559 </div>
560 560 <div class="pull-left">
561 561 <div class="btn-group">
562 562 <a
563 563 class="btn"
564 564 href="#"
565 565 onclick="$('input[class=filediff-collapse-state]').prop('checked', false); return false">${_('Expand All')}</a>
566 566 <a
567 567 class="btn"
568 568 href="#"
569 569 onclick="$('input[class=filediff-collapse-state]').prop('checked', true); return false">${_('Collapse All')}</a>
570 570 <a
571 571 class="btn"
572 572 href="#"
573 573 onclick="return Rhodecode.comments.toggleWideMode(this)">${_('Wide Mode')}</a>
574 574 </div>
575 575 </div>
576 576 </div>
577 577 </%def>
General Comments 0
You need to be logged in to leave comments. Login now