##// END OF EJS Templates
comments: introduce new draft comments....
milka -
r4540:25406ecd default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -0,0 +1,53 b''
1 # -*- coding: utf-8 -*-
2
3 import logging
4 from sqlalchemy import *
5
6 from alembic.migration import MigrationContext
7 from alembic.operations import Operations
8
9 from rhodecode.lib.dbmigrate.versions import _reset_base
10 from rhodecode.model import meta, init_model_encryption
11
12
13 log = logging.getLogger(__name__)
14
15
16 def upgrade(migrate_engine):
17 """
18 Upgrade operations go here.
19 Don't create your own engine; bind migrate_engine to your metadata
20 """
21 _reset_base(migrate_engine)
22 from rhodecode.lib.dbmigrate.schema import db_4_20_0_0 as db
23
24 init_model_encryption(db)
25
26 context = MigrationContext.configure(migrate_engine.connect())
27 op = Operations(context)
28
29 table = db.ChangesetComment.__table__
30 with op.batch_alter_table(table.name) as batch_op:
31 new_column = Column('draft', Boolean(), nullable=True)
32 batch_op.add_column(new_column)
33
34 _set_default_as_non_draft(op, meta.Session)
35
36
37 def downgrade(migrate_engine):
38 meta = MetaData()
39 meta.bind = migrate_engine
40
41
42 def fixups(models, _SESSION):
43 pass
44
45
46 def _set_default_as_non_draft(op, session):
47 params = {'draft': False}
48 query = text(
49 'UPDATE changeset_comments SET draft = :draft'
50 ).bindparams(**params)
51 op.execute(query)
52 session().commit()
53
@@ -1,60 +1,60 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 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 os
22 22 from collections import OrderedDict
23 23
24 24 import sys
25 25 import platform
26 26
27 27 VERSION = tuple(open(os.path.join(
28 28 os.path.dirname(__file__), 'VERSION')).read().split('.'))
29 29
30 30 BACKENDS = OrderedDict()
31 31
32 32 BACKENDS['hg'] = 'Mercurial repository'
33 33 BACKENDS['git'] = 'Git repository'
34 34 BACKENDS['svn'] = 'Subversion repository'
35 35
36 36
37 37 CELERY_ENABLED = False
38 38 CELERY_EAGER = False
39 39
40 40 # link to config for pyramid
41 41 CONFIG = {}
42 42
43 43 # Populated with the settings dictionary from application init in
44 44 # rhodecode.conf.environment.load_pyramid_environment
45 45 PYRAMID_SETTINGS = {}
46 46
47 47 # Linked module for extensions
48 48 EXTENSIONS = {}
49 49
50 50 __version__ = ('.'.join((str(each) for each in VERSION[:3])))
51 __dbversion__ = 110 # defines current db version for migrations
51 __dbversion__ = 111 # defines current db version for migrations
52 52 __platform__ = platform.system()
53 53 __license__ = 'AGPLv3, and Commercial License'
54 54 __author__ = 'RhodeCode GmbH'
55 55 __url__ = 'https://code.rhodecode.com'
56 56
57 57 is_windows = __platform__ in ['Windows']
58 58 is_unix = not is_windows
59 59 is_test = False
60 60 disable_error_handler = False
@@ -1,1816 +1,1826 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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 collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest, HTTPConflict)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.exceptions import CommentVersionMismatch
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 EmptyCommit, UpdateFailureReason, unicode_to_reference)
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import CommentsModel
49 49 from rhodecode.model.db import (
50 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
50 func, false, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
51 51 PullRequestReviewers)
52 52 from rhodecode.model.forms import PullRequestForm
53 53 from rhodecode.model.meta import Session
54 54 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
55 55 from rhodecode.model.scm import ScmModel
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 class RepoPullRequestsView(RepoAppView, DataGridAppView):
61 61
62 62 def load_default_context(self):
63 63 c = self._get_local_tmpl_context(include_app_defaults=True)
64 64 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
65 65 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
66 66 # backward compat., we use for OLD PRs a plain renderer
67 67 c.renderer = 'plain'
68 68 return c
69 69
70 70 def _get_pull_requests_list(
71 71 self, repo_name, source, filter_type, opened_by, statuses):
72 72
73 73 draw, start, limit = self._extract_chunk(self.request)
74 74 search_q, order_by, order_dir = self._extract_ordering(self.request)
75 75 _render = self.request.get_partial_renderer(
76 76 'rhodecode:templates/data_table/_dt_elements.mako')
77 77
78 78 # pagination
79 79
80 80 if filter_type == 'awaiting_review':
81 81 pull_requests = PullRequestModel().get_awaiting_review(
82 82 repo_name, search_q=search_q, source=source, opened_by=opened_by,
83 83 statuses=statuses, offset=start, length=limit,
84 84 order_by=order_by, order_dir=order_dir)
85 85 pull_requests_total_count = PullRequestModel().count_awaiting_review(
86 86 repo_name, search_q=search_q, source=source, statuses=statuses,
87 87 opened_by=opened_by)
88 88 elif filter_type == 'awaiting_my_review':
89 89 pull_requests = PullRequestModel().get_awaiting_my_review(
90 90 repo_name, search_q=search_q, source=source, opened_by=opened_by,
91 91 user_id=self._rhodecode_user.user_id, statuses=statuses,
92 92 offset=start, length=limit, order_by=order_by,
93 93 order_dir=order_dir)
94 94 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
95 95 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
96 96 statuses=statuses, opened_by=opened_by)
97 97 else:
98 98 pull_requests = PullRequestModel().get_all(
99 99 repo_name, search_q=search_q, source=source, opened_by=opened_by,
100 100 statuses=statuses, offset=start, length=limit,
101 101 order_by=order_by, order_dir=order_dir)
102 102 pull_requests_total_count = PullRequestModel().count_all(
103 103 repo_name, search_q=search_q, source=source, statuses=statuses,
104 104 opened_by=opened_by)
105 105
106 106 data = []
107 107 comments_model = CommentsModel()
108 108 for pr in pull_requests:
109 109 comments_count = comments_model.get_all_comments(
110 110 self.db_repo.repo_id, pull_request=pr, count_only=True)
111 111
112 112 data.append({
113 113 'name': _render('pullrequest_name',
114 114 pr.pull_request_id, pr.pull_request_state,
115 115 pr.work_in_progress, pr.target_repo.repo_name,
116 116 short=True),
117 117 'name_raw': pr.pull_request_id,
118 118 'status': _render('pullrequest_status',
119 119 pr.calculated_review_status()),
120 120 'title': _render('pullrequest_title', pr.title, pr.description),
121 121 'description': h.escape(pr.description),
122 122 'updated_on': _render('pullrequest_updated_on',
123 123 h.datetime_to_time(pr.updated_on)),
124 124 'updated_on_raw': h.datetime_to_time(pr.updated_on),
125 125 'created_on': _render('pullrequest_updated_on',
126 126 h.datetime_to_time(pr.created_on)),
127 127 'created_on_raw': h.datetime_to_time(pr.created_on),
128 128 'state': pr.pull_request_state,
129 129 'author': _render('pullrequest_author',
130 130 pr.author.full_contact, ),
131 131 'author_raw': pr.author.full_name,
132 132 'comments': _render('pullrequest_comments', comments_count),
133 133 'comments_raw': comments_count,
134 134 'closed': pr.is_closed(),
135 135 })
136 136
137 137 data = ({
138 138 'draw': draw,
139 139 'data': data,
140 140 'recordsTotal': pull_requests_total_count,
141 141 'recordsFiltered': pull_requests_total_count,
142 142 })
143 143 return data
144 144
145 145 @LoginRequired()
146 146 @HasRepoPermissionAnyDecorator(
147 147 'repository.read', 'repository.write', 'repository.admin')
148 148 @view_config(
149 149 route_name='pullrequest_show_all', request_method='GET',
150 150 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
151 151 def pull_request_list(self):
152 152 c = self.load_default_context()
153 153
154 154 req_get = self.request.GET
155 155 c.source = str2bool(req_get.get('source'))
156 156 c.closed = str2bool(req_get.get('closed'))
157 157 c.my = str2bool(req_get.get('my'))
158 158 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
159 159 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
160 160
161 161 c.active = 'open'
162 162 if c.my:
163 163 c.active = 'my'
164 164 if c.closed:
165 165 c.active = 'closed'
166 166 if c.awaiting_review and not c.source:
167 167 c.active = 'awaiting'
168 168 if c.source and not c.awaiting_review:
169 169 c.active = 'source'
170 170 if c.awaiting_my_review:
171 171 c.active = 'awaiting_my'
172 172
173 173 return self._get_template_context(c)
174 174
175 175 @LoginRequired()
176 176 @HasRepoPermissionAnyDecorator(
177 177 'repository.read', 'repository.write', 'repository.admin')
178 178 @view_config(
179 179 route_name='pullrequest_show_all_data', request_method='GET',
180 180 renderer='json_ext', xhr=True)
181 181 def pull_request_list_data(self):
182 182 self.load_default_context()
183 183
184 184 # additional filters
185 185 req_get = self.request.GET
186 186 source = str2bool(req_get.get('source'))
187 187 closed = str2bool(req_get.get('closed'))
188 188 my = str2bool(req_get.get('my'))
189 189 awaiting_review = str2bool(req_get.get('awaiting_review'))
190 190 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
191 191
192 192 filter_type = 'awaiting_review' if awaiting_review \
193 193 else 'awaiting_my_review' if awaiting_my_review \
194 194 else None
195 195
196 196 opened_by = None
197 197 if my:
198 198 opened_by = [self._rhodecode_user.user_id]
199 199
200 200 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
201 201 if closed:
202 202 statuses = [PullRequest.STATUS_CLOSED]
203 203
204 204 data = self._get_pull_requests_list(
205 205 repo_name=self.db_repo_name, source=source,
206 206 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
207 207
208 208 return data
209 209
210 210 def _is_diff_cache_enabled(self, target_repo):
211 211 caching_enabled = self._get_general_setting(
212 212 target_repo, 'rhodecode_diff_cache')
213 213 log.debug('Diff caching enabled: %s', caching_enabled)
214 214 return caching_enabled
215 215
216 216 def _get_diffset(self, source_repo_name, source_repo,
217 217 ancestor_commit,
218 218 source_ref_id, target_ref_id,
219 219 target_commit, source_commit, diff_limit, file_limit,
220 220 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
221 221
222 222 if use_ancestor:
223 223 # we might want to not use it for versions
224 224 target_ref_id = ancestor_commit.raw_id
225 225
226 226 vcs_diff = PullRequestModel().get_diff(
227 227 source_repo, source_ref_id, target_ref_id,
228 228 hide_whitespace_changes, diff_context)
229 229
230 230 diff_processor = diffs.DiffProcessor(
231 231 vcs_diff, format='newdiff', diff_limit=diff_limit,
232 232 file_limit=file_limit, show_full_diff=fulldiff)
233 233
234 234 _parsed = diff_processor.prepare()
235 235
236 236 diffset = codeblocks.DiffSet(
237 237 repo_name=self.db_repo_name,
238 238 source_repo_name=source_repo_name,
239 239 source_node_getter=codeblocks.diffset_node_getter(target_commit),
240 240 target_node_getter=codeblocks.diffset_node_getter(source_commit),
241 241 )
242 242 diffset = self.path_filter.render_patchset_filtered(
243 243 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
244 244
245 245 return diffset
246 246
247 247 def _get_range_diffset(self, source_scm, source_repo,
248 248 commit1, commit2, diff_limit, file_limit,
249 249 fulldiff, hide_whitespace_changes, diff_context):
250 250 vcs_diff = source_scm.get_diff(
251 251 commit1, commit2,
252 252 ignore_whitespace=hide_whitespace_changes,
253 253 context=diff_context)
254 254
255 255 diff_processor = diffs.DiffProcessor(
256 256 vcs_diff, format='newdiff', diff_limit=diff_limit,
257 257 file_limit=file_limit, show_full_diff=fulldiff)
258 258
259 259 _parsed = diff_processor.prepare()
260 260
261 261 diffset = codeblocks.DiffSet(
262 262 repo_name=source_repo.repo_name,
263 263 source_node_getter=codeblocks.diffset_node_getter(commit1),
264 264 target_node_getter=codeblocks.diffset_node_getter(commit2))
265 265
266 266 diffset = self.path_filter.render_patchset_filtered(
267 267 diffset, _parsed, commit1.raw_id, commit2.raw_id)
268 268
269 269 return diffset
270 270
271 def register_comments_vars(self, c, pull_request, versions):
271 def register_comments_vars(self, c, pull_request, versions, include_drafts=True):
272 272 comments_model = CommentsModel()
273 273
274 274 # GENERAL COMMENTS with versions #
275 275 q = comments_model._all_general_comments_of_pull_request(pull_request)
276 276 q = q.order_by(ChangesetComment.comment_id.asc())
277 if not include_drafts:
278 q = q.filter(ChangesetComment.draft == false())
277 279 general_comments = q
278 280
279 281 # pick comments we want to render at current version
280 282 c.comment_versions = comments_model.aggregate_comments(
281 283 general_comments, versions, c.at_version_num)
282 284
283 285 # INLINE COMMENTS with versions #
284 286 q = comments_model._all_inline_comments_of_pull_request(pull_request)
285 287 q = q.order_by(ChangesetComment.comment_id.asc())
288 if not include_drafts:
289 q = q.filter(ChangesetComment.draft == false())
286 290 inline_comments = q
287 291
288 292 c.inline_versions = comments_model.aggregate_comments(
289 293 inline_comments, versions, c.at_version_num, inline=True)
290 294
291 295 # Comments inline+general
292 296 if c.at_version:
293 297 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
294 298 c.comments = c.comment_versions[c.at_version_num]['display']
295 299 else:
296 300 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
297 301 c.comments = c.comment_versions[c.at_version_num]['until']
298 302
299 303 return general_comments, inline_comments
300 304
301 305 @LoginRequired()
302 306 @HasRepoPermissionAnyDecorator(
303 307 'repository.read', 'repository.write', 'repository.admin')
304 308 @view_config(
305 309 route_name='pullrequest_show', request_method='GET',
306 310 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
307 311 def pull_request_show(self):
308 312 _ = self.request.translate
309 313 c = self.load_default_context()
310 314
311 315 pull_request = PullRequest.get_or_404(
312 316 self.request.matchdict['pull_request_id'])
313 317 pull_request_id = pull_request.pull_request_id
314 318
315 319 c.state_progressing = pull_request.is_state_changing()
316 320 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
317 321
318 322 _new_state = {
319 323 'created': PullRequest.STATE_CREATED,
320 324 }.get(self.request.GET.get('force_state'))
321 325
322 326 if c.is_super_admin and _new_state:
323 327 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
324 328 h.flash(
325 329 _('Pull Request state was force changed to `{}`').format(_new_state),
326 330 category='success')
327 331 Session().commit()
328 332
329 333 raise HTTPFound(h.route_path(
330 334 'pullrequest_show', repo_name=self.db_repo_name,
331 335 pull_request_id=pull_request_id))
332 336
333 337 version = self.request.GET.get('version')
334 338 from_version = self.request.GET.get('from_version') or version
335 339 merge_checks = self.request.GET.get('merge_checks')
336 340 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
337 341 force_refresh = str2bool(self.request.GET.get('force_refresh'))
338 342 c.range_diff_on = self.request.GET.get('range-diff') == "1"
339 343
340 344 # fetch global flags of ignore ws or context lines
341 345 diff_context = diffs.get_diff_context(self.request)
342 346 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
343 347
344 348 (pull_request_latest,
345 349 pull_request_at_ver,
346 350 pull_request_display_obj,
347 351 at_version) = PullRequestModel().get_pr_version(
348 352 pull_request_id, version=version)
349 353
350 354 pr_closed = pull_request_latest.is_closed()
351 355
352 356 if pr_closed and (version or from_version):
353 357 # not allow to browse versions for closed PR
354 358 raise HTTPFound(h.route_path(
355 359 'pullrequest_show', repo_name=self.db_repo_name,
356 360 pull_request_id=pull_request_id))
357 361
358 362 versions = pull_request_display_obj.versions()
359 363 # used to store per-commit range diffs
360 364 c.changes = collections.OrderedDict()
361 365
362 366 c.at_version = at_version
363 367 c.at_version_num = (at_version
364 368 if at_version and at_version != PullRequest.LATEST_VER
365 369 else None)
366 370
367 371 c.at_version_index = ChangesetComment.get_index_from_version(
368 372 c.at_version_num, versions)
369 373
370 374 (prev_pull_request_latest,
371 375 prev_pull_request_at_ver,
372 376 prev_pull_request_display_obj,
373 377 prev_at_version) = PullRequestModel().get_pr_version(
374 378 pull_request_id, version=from_version)
375 379
376 380 c.from_version = prev_at_version
377 381 c.from_version_num = (prev_at_version
378 382 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
379 383 else None)
380 384 c.from_version_index = ChangesetComment.get_index_from_version(
381 385 c.from_version_num, versions)
382 386
383 387 # define if we're in COMPARE mode or VIEW at version mode
384 388 compare = at_version != prev_at_version
385 389
386 390 # pull_requests repo_name we opened it against
387 391 # ie. target_repo must match
388 392 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
389 393 log.warning('Mismatch between the current repo: %s, and target %s',
390 394 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
391 395 raise HTTPNotFound()
392 396
393 397 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
394 398
395 399 c.pull_request = pull_request_display_obj
396 400 c.renderer = pull_request_at_ver.description_renderer or c.renderer
397 401 c.pull_request_latest = pull_request_latest
398 402
399 403 # inject latest version
400 404 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
401 405 c.versions = versions + [latest_ver]
402 406
403 407 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
404 408 c.allowed_to_change_status = False
405 409 c.allowed_to_update = False
406 410 c.allowed_to_merge = False
407 411 c.allowed_to_delete = False
408 412 c.allowed_to_comment = False
409 413 c.allowed_to_close = False
410 414 else:
411 415 can_change_status = PullRequestModel().check_user_change_status(
412 416 pull_request_at_ver, self._rhodecode_user)
413 417 c.allowed_to_change_status = can_change_status and not pr_closed
414 418
415 419 c.allowed_to_update = PullRequestModel().check_user_update(
416 420 pull_request_latest, self._rhodecode_user) and not pr_closed
417 421 c.allowed_to_merge = PullRequestModel().check_user_merge(
418 422 pull_request_latest, self._rhodecode_user) and not pr_closed
419 423 c.allowed_to_delete = PullRequestModel().check_user_delete(
420 424 pull_request_latest, self._rhodecode_user) and not pr_closed
421 425 c.allowed_to_comment = not pr_closed
422 426 c.allowed_to_close = c.allowed_to_merge and not pr_closed
423 427
424 428 c.forbid_adding_reviewers = False
425 429 c.forbid_author_to_review = False
426 430 c.forbid_commit_author_to_review = False
427 431
428 432 if pull_request_latest.reviewer_data and \
429 433 'rules' in pull_request_latest.reviewer_data:
430 434 rules = pull_request_latest.reviewer_data['rules'] or {}
431 435 try:
432 436 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
433 437 c.forbid_author_to_review = rules.get('forbid_author_to_review')
434 438 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
435 439 except Exception:
436 440 pass
437 441
438 442 # check merge capabilities
439 443 _merge_check = MergeCheck.validate(
440 444 pull_request_latest, auth_user=self._rhodecode_user,
441 445 translator=self.request.translate,
442 446 force_shadow_repo_refresh=force_refresh)
443 447
444 448 c.pr_merge_errors = _merge_check.error_details
445 449 c.pr_merge_possible = not _merge_check.failed
446 450 c.pr_merge_message = _merge_check.merge_msg
447 451 c.pr_merge_source_commit = _merge_check.source_commit
448 452 c.pr_merge_target_commit = _merge_check.target_commit
449 453
450 454 c.pr_merge_info = MergeCheck.get_merge_conditions(
451 455 pull_request_latest, translator=self.request.translate)
452 456
453 457 c.pull_request_review_status = _merge_check.review_status
454 458 if merge_checks:
455 459 self.request.override_renderer = \
456 460 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
457 461 return self._get_template_context(c)
458 462
459 463 c.reviewers_count = pull_request.reviewers_count
460 464 c.observers_count = pull_request.observers_count
461 465
462 466 # reviewers and statuses
463 467 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
464 468 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
465 469 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
466 470
467 471 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
468 472 member_reviewer = h.reviewer_as_json(
469 473 member, reasons=reasons, mandatory=mandatory,
470 474 role=review_obj.role,
471 475 user_group=review_obj.rule_user_group_data()
472 476 )
473 477
474 478 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
475 479 member_reviewer['review_status'] = current_review_status
476 480 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
477 481 member_reviewer['allowed_to_update'] = c.allowed_to_update
478 482 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
479 483
480 484 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
481 485
482 486 for observer_obj, member in pull_request_at_ver.observers():
483 487 member_observer = h.reviewer_as_json(
484 488 member, reasons=[], mandatory=False,
485 489 role=observer_obj.role,
486 490 user_group=observer_obj.rule_user_group_data()
487 491 )
488 492 member_observer['allowed_to_update'] = c.allowed_to_update
489 493 c.pull_request_set_observers_data_json['observers'].append(member_observer)
490 494
491 495 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
492 496
493 497 general_comments, inline_comments = \
494 498 self.register_comments_vars(c, pull_request_latest, versions)
495 499
496 500 # TODOs
497 501 c.unresolved_comments = CommentsModel() \
498 502 .get_pull_request_unresolved_todos(pull_request_latest)
499 503 c.resolved_comments = CommentsModel() \
500 504 .get_pull_request_resolved_todos(pull_request_latest)
501 505
502 506 # if we use version, then do not show later comments
503 507 # than current version
504 508 display_inline_comments = collections.defaultdict(
505 509 lambda: collections.defaultdict(list))
506 510 for co in inline_comments:
507 511 if c.at_version_num:
508 512 # pick comments that are at least UPTO given version, so we
509 513 # don't render comments for higher version
510 514 should_render = co.pull_request_version_id and \
511 515 co.pull_request_version_id <= c.at_version_num
512 516 else:
513 517 # showing all, for 'latest'
514 518 should_render = True
515 519
516 520 if should_render:
517 521 display_inline_comments[co.f_path][co.line_no].append(co)
518 522
519 523 # load diff data into template context, if we use compare mode then
520 524 # diff is calculated based on changes between versions of PR
521 525
522 526 source_repo = pull_request_at_ver.source_repo
523 527 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
524 528
525 529 target_repo = pull_request_at_ver.target_repo
526 530 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
527 531
528 532 if compare:
529 533 # in compare switch the diff base to latest commit from prev version
530 534 target_ref_id = prev_pull_request_display_obj.revisions[0]
531 535
532 536 # despite opening commits for bookmarks/branches/tags, we always
533 537 # convert this to rev to prevent changes after bookmark or branch change
534 538 c.source_ref_type = 'rev'
535 539 c.source_ref = source_ref_id
536 540
537 541 c.target_ref_type = 'rev'
538 542 c.target_ref = target_ref_id
539 543
540 544 c.source_repo = source_repo
541 545 c.target_repo = target_repo
542 546
543 547 c.commit_ranges = []
544 548 source_commit = EmptyCommit()
545 549 target_commit = EmptyCommit()
546 550 c.missing_requirements = False
547 551
548 552 source_scm = source_repo.scm_instance()
549 553 target_scm = target_repo.scm_instance()
550 554
551 555 shadow_scm = None
552 556 try:
553 557 shadow_scm = pull_request_latest.get_shadow_repo()
554 558 except Exception:
555 559 log.debug('Failed to get shadow repo', exc_info=True)
556 560 # try first the existing source_repo, and then shadow
557 561 # repo if we can obtain one
558 562 commits_source_repo = source_scm
559 563 if shadow_scm:
560 564 commits_source_repo = shadow_scm
561 565
562 566 c.commits_source_repo = commits_source_repo
563 567 c.ancestor = None # set it to None, to hide it from PR view
564 568
565 569 # empty version means latest, so we keep this to prevent
566 570 # double caching
567 571 version_normalized = version or PullRequest.LATEST_VER
568 572 from_version_normalized = from_version or PullRequest.LATEST_VER
569 573
570 574 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
571 575 cache_file_path = diff_cache_exist(
572 576 cache_path, 'pull_request', pull_request_id, version_normalized,
573 577 from_version_normalized, source_ref_id, target_ref_id,
574 578 hide_whitespace_changes, diff_context, c.fulldiff)
575 579
576 580 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
577 581 force_recache = self.get_recache_flag()
578 582
579 583 cached_diff = None
580 584 if caching_enabled:
581 585 cached_diff = load_cached_diff(cache_file_path)
582 586
583 587 has_proper_commit_cache = (
584 588 cached_diff and cached_diff.get('commits')
585 589 and len(cached_diff.get('commits', [])) == 5
586 590 and cached_diff.get('commits')[0]
587 591 and cached_diff.get('commits')[3])
588 592
589 593 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
590 594 diff_commit_cache = \
591 595 (ancestor_commit, commit_cache, missing_requirements,
592 596 source_commit, target_commit) = cached_diff['commits']
593 597 else:
594 598 # NOTE(marcink): we reach potentially unreachable errors when a PR has
595 599 # merge errors resulting in potentially hidden commits in the shadow repo.
596 600 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
597 601 and _merge_check.merge_response
598 602 maybe_unreachable = maybe_unreachable \
599 603 and _merge_check.merge_response.metadata.get('unresolved_files')
600 604 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
601 605 diff_commit_cache = \
602 606 (ancestor_commit, commit_cache, missing_requirements,
603 607 source_commit, target_commit) = self.get_commits(
604 608 commits_source_repo,
605 609 pull_request_at_ver,
606 610 source_commit,
607 611 source_ref_id,
608 612 source_scm,
609 613 target_commit,
610 614 target_ref_id,
611 615 target_scm,
612 616 maybe_unreachable=maybe_unreachable)
613 617
614 618 # register our commit range
615 619 for comm in commit_cache.values():
616 620 c.commit_ranges.append(comm)
617 621
618 622 c.missing_requirements = missing_requirements
619 623 c.ancestor_commit = ancestor_commit
620 624 c.statuses = source_repo.statuses(
621 625 [x.raw_id for x in c.commit_ranges])
622 626
623 627 # auto collapse if we have more than limit
624 628 collapse_limit = diffs.DiffProcessor._collapse_commits_over
625 629 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
626 630 c.compare_mode = compare
627 631
628 632 # diff_limit is the old behavior, will cut off the whole diff
629 633 # if the limit is applied otherwise will just hide the
630 634 # big files from the front-end
631 635 diff_limit = c.visual.cut_off_limit_diff
632 636 file_limit = c.visual.cut_off_limit_file
633 637
634 638 c.missing_commits = False
635 639 if (c.missing_requirements
636 640 or isinstance(source_commit, EmptyCommit)
637 641 or source_commit == target_commit):
638 642
639 643 c.missing_commits = True
640 644 else:
641 645 c.inline_comments = display_inline_comments
642 646
643 647 use_ancestor = True
644 648 if from_version_normalized != version_normalized:
645 649 use_ancestor = False
646 650
647 651 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
648 652 if not force_recache and has_proper_diff_cache:
649 653 c.diffset = cached_diff['diff']
650 654 else:
651 655 try:
652 656 c.diffset = self._get_diffset(
653 657 c.source_repo.repo_name, commits_source_repo,
654 658 c.ancestor_commit,
655 659 source_ref_id, target_ref_id,
656 660 target_commit, source_commit,
657 661 diff_limit, file_limit, c.fulldiff,
658 662 hide_whitespace_changes, diff_context,
659 663 use_ancestor=use_ancestor
660 664 )
661 665
662 666 # save cached diff
663 667 if caching_enabled:
664 668 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
665 669 except CommitDoesNotExistError:
666 670 log.exception('Failed to generate diffset')
667 671 c.missing_commits = True
668 672
669 673 if not c.missing_commits:
670 674
671 675 c.limited_diff = c.diffset.limited_diff
672 676
673 677 # calculate removed files that are bound to comments
674 678 comment_deleted_files = [
675 679 fname for fname in display_inline_comments
676 680 if fname not in c.diffset.file_stats]
677 681
678 682 c.deleted_files_comments = collections.defaultdict(dict)
679 683 for fname, per_line_comments in display_inline_comments.items():
680 684 if fname in comment_deleted_files:
681 685 c.deleted_files_comments[fname]['stats'] = 0
682 686 c.deleted_files_comments[fname]['comments'] = list()
683 687 for lno, comments in per_line_comments.items():
684 688 c.deleted_files_comments[fname]['comments'].extend(comments)
685 689
686 690 # maybe calculate the range diff
687 691 if c.range_diff_on:
688 692 # TODO(marcink): set whitespace/context
689 693 context_lcl = 3
690 694 ign_whitespace_lcl = False
691 695
692 696 for commit in c.commit_ranges:
693 697 commit2 = commit
694 698 commit1 = commit.first_parent
695 699
696 700 range_diff_cache_file_path = diff_cache_exist(
697 701 cache_path, 'diff', commit.raw_id,
698 702 ign_whitespace_lcl, context_lcl, c.fulldiff)
699 703
700 704 cached_diff = None
701 705 if caching_enabled:
702 706 cached_diff = load_cached_diff(range_diff_cache_file_path)
703 707
704 708 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
705 709 if not force_recache and has_proper_diff_cache:
706 710 diffset = cached_diff['diff']
707 711 else:
708 712 diffset = self._get_range_diffset(
709 713 commits_source_repo, source_repo,
710 714 commit1, commit2, diff_limit, file_limit,
711 715 c.fulldiff, ign_whitespace_lcl, context_lcl
712 716 )
713 717
714 718 # save cached diff
715 719 if caching_enabled:
716 720 cache_diff(range_diff_cache_file_path, diffset, None)
717 721
718 722 c.changes[commit.raw_id] = diffset
719 723
720 724 # this is a hack to properly display links, when creating PR, the
721 725 # compare view and others uses different notation, and
722 726 # compare_commits.mako renders links based on the target_repo.
723 727 # We need to swap that here to generate it properly on the html side
724 728 c.target_repo = c.source_repo
725 729
726 730 c.commit_statuses = ChangesetStatus.STATUSES
727 731
728 732 c.show_version_changes = not pr_closed
729 733 if c.show_version_changes:
730 734 cur_obj = pull_request_at_ver
731 735 prev_obj = prev_pull_request_at_ver
732 736
733 737 old_commit_ids = prev_obj.revisions
734 738 new_commit_ids = cur_obj.revisions
735 739 commit_changes = PullRequestModel()._calculate_commit_id_changes(
736 740 old_commit_ids, new_commit_ids)
737 741 c.commit_changes_summary = commit_changes
738 742
739 743 # calculate the diff for commits between versions
740 744 c.commit_changes = []
741 745
742 746 def mark(cs, fw):
743 747 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
744 748
745 749 for c_type, raw_id in mark(commit_changes.added, 'a') \
746 750 + mark(commit_changes.removed, 'r') \
747 751 + mark(commit_changes.common, 'c'):
748 752
749 753 if raw_id in commit_cache:
750 754 commit = commit_cache[raw_id]
751 755 else:
752 756 try:
753 757 commit = commits_source_repo.get_commit(raw_id)
754 758 except CommitDoesNotExistError:
755 759 # in case we fail extracting still use "dummy" commit
756 760 # for display in commit diff
757 761 commit = h.AttributeDict(
758 762 {'raw_id': raw_id,
759 763 'message': 'EMPTY or MISSING COMMIT'})
760 764 c.commit_changes.append([c_type, commit])
761 765
762 766 # current user review statuses for each version
763 767 c.review_versions = {}
764 768 is_reviewer = PullRequestModel().is_user_reviewer(
765 769 pull_request, self._rhodecode_user)
766 770 if is_reviewer:
767 771 for co in general_comments:
768 772 if co.author.user_id == self._rhodecode_user.user_id:
769 773 status = co.status_change
770 774 if status:
771 775 _ver_pr = status[0].comment.pull_request_version_id
772 776 c.review_versions[_ver_pr] = status[0]
773 777
774 778 return self._get_template_context(c)
775 779
776 780 def get_commits(
777 781 self, commits_source_repo, pull_request_at_ver, source_commit,
778 782 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
779 783 maybe_unreachable=False):
780 784
781 785 commit_cache = collections.OrderedDict()
782 786 missing_requirements = False
783 787
784 788 try:
785 789 pre_load = ["author", "date", "message", "branch", "parents"]
786 790
787 791 pull_request_commits = pull_request_at_ver.revisions
788 792 log.debug('Loading %s commits from %s',
789 793 len(pull_request_commits), commits_source_repo)
790 794
791 795 for rev in pull_request_commits:
792 796 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
793 797 maybe_unreachable=maybe_unreachable)
794 798 commit_cache[comm.raw_id] = comm
795 799
796 800 # Order here matters, we first need to get target, and then
797 801 # the source
798 802 target_commit = commits_source_repo.get_commit(
799 803 commit_id=safe_str(target_ref_id))
800 804
801 805 source_commit = commits_source_repo.get_commit(
802 806 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
803 807 except CommitDoesNotExistError:
804 808 log.warning('Failed to get commit from `{}` repo'.format(
805 809 commits_source_repo), exc_info=True)
806 810 except RepositoryRequirementError:
807 811 log.warning('Failed to get all required data from repo', exc_info=True)
808 812 missing_requirements = True
809 813
810 814 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
811 815
812 816 try:
813 817 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
814 818 except Exception:
815 819 ancestor_commit = None
816 820
817 821 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
818 822
819 823 def assure_not_empty_repo(self):
820 824 _ = self.request.translate
821 825
822 826 try:
823 827 self.db_repo.scm_instance().get_commit()
824 828 except EmptyRepositoryError:
825 829 h.flash(h.literal(_('There are no commits yet')),
826 830 category='warning')
827 831 raise HTTPFound(
828 832 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
829 833
830 834 @LoginRequired()
831 835 @NotAnonymous()
832 836 @HasRepoPermissionAnyDecorator(
833 837 'repository.read', 'repository.write', 'repository.admin')
834 838 @view_config(
835 839 route_name='pullrequest_new', request_method='GET',
836 840 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
837 841 def pull_request_new(self):
838 842 _ = self.request.translate
839 843 c = self.load_default_context()
840 844
841 845 self.assure_not_empty_repo()
842 846 source_repo = self.db_repo
843 847
844 848 commit_id = self.request.GET.get('commit')
845 849 branch_ref = self.request.GET.get('branch')
846 850 bookmark_ref = self.request.GET.get('bookmark')
847 851
848 852 try:
849 853 source_repo_data = PullRequestModel().generate_repo_data(
850 854 source_repo, commit_id=commit_id,
851 855 branch=branch_ref, bookmark=bookmark_ref,
852 856 translator=self.request.translate)
853 857 except CommitDoesNotExistError as e:
854 858 log.exception(e)
855 859 h.flash(_('Commit does not exist'), 'error')
856 860 raise HTTPFound(
857 861 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
858 862
859 863 default_target_repo = source_repo
860 864
861 865 if source_repo.parent and c.has_origin_repo_read_perm:
862 866 parent_vcs_obj = source_repo.parent.scm_instance()
863 867 if parent_vcs_obj and not parent_vcs_obj.is_empty():
864 868 # change default if we have a parent repo
865 869 default_target_repo = source_repo.parent
866 870
867 871 target_repo_data = PullRequestModel().generate_repo_data(
868 872 default_target_repo, translator=self.request.translate)
869 873
870 874 selected_source_ref = source_repo_data['refs']['selected_ref']
871 875 title_source_ref = ''
872 876 if selected_source_ref:
873 877 title_source_ref = selected_source_ref.split(':', 2)[1]
874 878 c.default_title = PullRequestModel().generate_pullrequest_title(
875 879 source=source_repo.repo_name,
876 880 source_ref=title_source_ref,
877 881 target=default_target_repo.repo_name
878 882 )
879 883
880 884 c.default_repo_data = {
881 885 'source_repo_name': source_repo.repo_name,
882 886 'source_refs_json': json.dumps(source_repo_data),
883 887 'target_repo_name': default_target_repo.repo_name,
884 888 'target_refs_json': json.dumps(target_repo_data),
885 889 }
886 890 c.default_source_ref = selected_source_ref
887 891
888 892 return self._get_template_context(c)
889 893
890 894 @LoginRequired()
891 895 @NotAnonymous()
892 896 @HasRepoPermissionAnyDecorator(
893 897 'repository.read', 'repository.write', 'repository.admin')
894 898 @view_config(
895 899 route_name='pullrequest_repo_refs', request_method='GET',
896 900 renderer='json_ext', xhr=True)
897 901 def pull_request_repo_refs(self):
898 902 self.load_default_context()
899 903 target_repo_name = self.request.matchdict['target_repo_name']
900 904 repo = Repository.get_by_repo_name(target_repo_name)
901 905 if not repo:
902 906 raise HTTPNotFound()
903 907
904 908 target_perm = HasRepoPermissionAny(
905 909 'repository.read', 'repository.write', 'repository.admin')(
906 910 target_repo_name)
907 911 if not target_perm:
908 912 raise HTTPNotFound()
909 913
910 914 return PullRequestModel().generate_repo_data(
911 915 repo, translator=self.request.translate)
912 916
913 917 @LoginRequired()
914 918 @NotAnonymous()
915 919 @HasRepoPermissionAnyDecorator(
916 920 'repository.read', 'repository.write', 'repository.admin')
917 921 @view_config(
918 922 route_name='pullrequest_repo_targets', request_method='GET',
919 923 renderer='json_ext', xhr=True)
920 924 def pullrequest_repo_targets(self):
921 925 _ = self.request.translate
922 926 filter_query = self.request.GET.get('query')
923 927
924 928 # get the parents
925 929 parent_target_repos = []
926 930 if self.db_repo.parent:
927 931 parents_query = Repository.query() \
928 932 .order_by(func.length(Repository.repo_name)) \
929 933 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
930 934
931 935 if filter_query:
932 936 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
933 937 parents_query = parents_query.filter(
934 938 Repository.repo_name.ilike(ilike_expression))
935 939 parents = parents_query.limit(20).all()
936 940
937 941 for parent in parents:
938 942 parent_vcs_obj = parent.scm_instance()
939 943 if parent_vcs_obj and not parent_vcs_obj.is_empty():
940 944 parent_target_repos.append(parent)
941 945
942 946 # get other forks, and repo itself
943 947 query = Repository.query() \
944 948 .order_by(func.length(Repository.repo_name)) \
945 949 .filter(
946 950 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
947 951 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
948 952 ) \
949 953 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
950 954
951 955 if filter_query:
952 956 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
953 957 query = query.filter(Repository.repo_name.ilike(ilike_expression))
954 958
955 959 limit = max(20 - len(parent_target_repos), 5) # not less then 5
956 960 target_repos = query.limit(limit).all()
957 961
958 962 all_target_repos = target_repos + parent_target_repos
959 963
960 964 repos = []
961 965 # This checks permissions to the repositories
962 966 for obj in ScmModel().get_repos(all_target_repos):
963 967 repos.append({
964 968 'id': obj['name'],
965 969 'text': obj['name'],
966 970 'type': 'repo',
967 971 'repo_id': obj['dbrepo']['repo_id'],
968 972 'repo_type': obj['dbrepo']['repo_type'],
969 973 'private': obj['dbrepo']['private'],
970 974
971 975 })
972 976
973 977 data = {
974 978 'more': False,
975 979 'results': [{
976 980 'text': _('Repositories'),
977 981 'children': repos
978 982 }] if repos else []
979 983 }
980 984 return data
981 985
982 986 def _get_existing_ids(self, post_data):
983 987 return filter(lambda e: e, map(safe_int, aslist(post_data.get('comments'), ',')))
984 988
985 989 @LoginRequired()
986 990 @NotAnonymous()
987 991 @HasRepoPermissionAnyDecorator(
988 992 'repository.read', 'repository.write', 'repository.admin')
989 993 @view_config(
990 994 route_name='pullrequest_comments', request_method='POST',
991 995 renderer='string_html', xhr=True)
992 996 def pullrequest_comments(self):
993 997 self.load_default_context()
994 998
995 999 pull_request = PullRequest.get_or_404(
996 1000 self.request.matchdict['pull_request_id'])
997 1001 pull_request_id = pull_request.pull_request_id
998 1002 version = self.request.GET.get('version')
999 1003
1000 1004 _render = self.request.get_partial_renderer(
1001 1005 'rhodecode:templates/base/sidebar.mako')
1002 1006 c = _render.get_call_context()
1003 1007
1004 1008 (pull_request_latest,
1005 1009 pull_request_at_ver,
1006 1010 pull_request_display_obj,
1007 1011 at_version) = PullRequestModel().get_pr_version(
1008 1012 pull_request_id, version=version)
1009 1013 versions = pull_request_display_obj.versions()
1010 1014 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1011 1015 c.versions = versions + [latest_ver]
1012 1016
1013 1017 c.at_version = at_version
1014 1018 c.at_version_num = (at_version
1015 1019 if at_version and at_version != PullRequest.LATEST_VER
1016 1020 else None)
1017 1021
1018 self.register_comments_vars(c, pull_request_latest, versions)
1022 self.register_comments_vars(c, pull_request_latest, versions, include_drafts=False)
1019 1023 all_comments = c.inline_comments_flat + c.comments
1020 1024
1021 1025 existing_ids = self._get_existing_ids(self.request.POST)
1022 1026 return _render('comments_table', all_comments, len(all_comments),
1023 1027 existing_ids=existing_ids)
1024 1028
1025 1029 @LoginRequired()
1026 1030 @NotAnonymous()
1027 1031 @HasRepoPermissionAnyDecorator(
1028 1032 'repository.read', 'repository.write', 'repository.admin')
1029 1033 @view_config(
1030 1034 route_name='pullrequest_todos', request_method='POST',
1031 1035 renderer='string_html', xhr=True)
1032 1036 def pullrequest_todos(self):
1033 1037 self.load_default_context()
1034 1038
1035 1039 pull_request = PullRequest.get_or_404(
1036 1040 self.request.matchdict['pull_request_id'])
1037 1041 pull_request_id = pull_request.pull_request_id
1038 1042 version = self.request.GET.get('version')
1039 1043
1040 1044 _render = self.request.get_partial_renderer(
1041 1045 'rhodecode:templates/base/sidebar.mako')
1042 1046 c = _render.get_call_context()
1043 1047 (pull_request_latest,
1044 1048 pull_request_at_ver,
1045 1049 pull_request_display_obj,
1046 1050 at_version) = PullRequestModel().get_pr_version(
1047 1051 pull_request_id, version=version)
1048 1052 versions = pull_request_display_obj.versions()
1049 1053 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1050 1054 c.versions = versions + [latest_ver]
1051 1055
1052 1056 c.at_version = at_version
1053 1057 c.at_version_num = (at_version
1054 1058 if at_version and at_version != PullRequest.LATEST_VER
1055 1059 else None)
1056 1060
1057 1061 c.unresolved_comments = CommentsModel() \
1058 .get_pull_request_unresolved_todos(pull_request)
1062 .get_pull_request_unresolved_todos(pull_request, include_drafts=False)
1059 1063 c.resolved_comments = CommentsModel() \
1060 .get_pull_request_resolved_todos(pull_request)
1064 .get_pull_request_resolved_todos(pull_request, include_drafts=False)
1061 1065
1062 1066 all_comments = c.unresolved_comments + c.resolved_comments
1063 1067 existing_ids = self._get_existing_ids(self.request.POST)
1064 1068 return _render('comments_table', all_comments, len(c.unresolved_comments),
1065 1069 todo_comments=True, existing_ids=existing_ids)
1066 1070
1067 1071 @LoginRequired()
1068 1072 @NotAnonymous()
1069 1073 @HasRepoPermissionAnyDecorator(
1070 1074 'repository.read', 'repository.write', 'repository.admin')
1071 1075 @CSRFRequired()
1072 1076 @view_config(
1073 1077 route_name='pullrequest_create', request_method='POST',
1074 1078 renderer=None)
1075 1079 def pull_request_create(self):
1076 1080 _ = self.request.translate
1077 1081 self.assure_not_empty_repo()
1078 1082 self.load_default_context()
1079 1083
1080 1084 controls = peppercorn.parse(self.request.POST.items())
1081 1085
1082 1086 try:
1083 1087 form = PullRequestForm(
1084 1088 self.request.translate, self.db_repo.repo_id)()
1085 1089 _form = form.to_python(controls)
1086 1090 except formencode.Invalid as errors:
1087 1091 if errors.error_dict.get('revisions'):
1088 1092 msg = 'Revisions: %s' % errors.error_dict['revisions']
1089 1093 elif errors.error_dict.get('pullrequest_title'):
1090 1094 msg = errors.error_dict.get('pullrequest_title')
1091 1095 else:
1092 1096 msg = _('Error creating pull request: {}').format(errors)
1093 1097 log.exception(msg)
1094 1098 h.flash(msg, 'error')
1095 1099
1096 1100 # would rather just go back to form ...
1097 1101 raise HTTPFound(
1098 1102 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1099 1103
1100 1104 source_repo = _form['source_repo']
1101 1105 source_ref = _form['source_ref']
1102 1106 target_repo = _form['target_repo']
1103 1107 target_ref = _form['target_ref']
1104 1108 commit_ids = _form['revisions'][::-1]
1105 1109 common_ancestor_id = _form['common_ancestor']
1106 1110
1107 1111 # find the ancestor for this pr
1108 1112 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1109 1113 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1110 1114
1111 1115 if not (source_db_repo or target_db_repo):
1112 1116 h.flash(_('source_repo or target repo not found'), category='error')
1113 1117 raise HTTPFound(
1114 1118 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1115 1119
1116 1120 # re-check permissions again here
1117 1121 # source_repo we must have read permissions
1118 1122
1119 1123 source_perm = HasRepoPermissionAny(
1120 1124 'repository.read', 'repository.write', 'repository.admin')(
1121 1125 source_db_repo.repo_name)
1122 1126 if not source_perm:
1123 1127 msg = _('Not Enough permissions to source repo `{}`.'.format(
1124 1128 source_db_repo.repo_name))
1125 1129 h.flash(msg, category='error')
1126 1130 # copy the args back to redirect
1127 1131 org_query = self.request.GET.mixed()
1128 1132 raise HTTPFound(
1129 1133 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1130 1134 _query=org_query))
1131 1135
1132 1136 # target repo we must have read permissions, and also later on
1133 1137 # we want to check branch permissions here
1134 1138 target_perm = HasRepoPermissionAny(
1135 1139 'repository.read', 'repository.write', 'repository.admin')(
1136 1140 target_db_repo.repo_name)
1137 1141 if not target_perm:
1138 1142 msg = _('Not Enough permissions to target repo `{}`.'.format(
1139 1143 target_db_repo.repo_name))
1140 1144 h.flash(msg, category='error')
1141 1145 # copy the args back to redirect
1142 1146 org_query = self.request.GET.mixed()
1143 1147 raise HTTPFound(
1144 1148 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1145 1149 _query=org_query))
1146 1150
1147 1151 source_scm = source_db_repo.scm_instance()
1148 1152 target_scm = target_db_repo.scm_instance()
1149 1153
1150 1154 source_ref_obj = unicode_to_reference(source_ref)
1151 1155 target_ref_obj = unicode_to_reference(target_ref)
1152 1156
1153 1157 source_commit = source_scm.get_commit(source_ref_obj.commit_id)
1154 1158 target_commit = target_scm.get_commit(target_ref_obj.commit_id)
1155 1159
1156 1160 ancestor = source_scm.get_common_ancestor(
1157 1161 source_commit.raw_id, target_commit.raw_id, target_scm)
1158 1162
1159 1163 # recalculate target ref based on ancestor
1160 1164 target_ref = ':'.join((target_ref_obj.type, target_ref_obj.name, ancestor))
1161 1165
1162 1166 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1163 1167 PullRequestModel().get_reviewer_functions()
1164 1168
1165 1169 # recalculate reviewers logic, to make sure we can validate this
1166 1170 reviewer_rules = get_default_reviewers_data(
1167 1171 self._rhodecode_db_user,
1168 1172 source_db_repo,
1169 1173 source_ref_obj,
1170 1174 target_db_repo,
1171 1175 target_ref_obj,
1172 1176 include_diff_info=False)
1173 1177
1174 1178 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1175 1179 observers = validate_observers(_form['observer_members'], reviewer_rules)
1176 1180
1177 1181 pullrequest_title = _form['pullrequest_title']
1178 1182 title_source_ref = source_ref_obj.name
1179 1183 if not pullrequest_title:
1180 1184 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1181 1185 source=source_repo,
1182 1186 source_ref=title_source_ref,
1183 1187 target=target_repo
1184 1188 )
1185 1189
1186 1190 description = _form['pullrequest_desc']
1187 1191 description_renderer = _form['description_renderer']
1188 1192
1189 1193 try:
1190 1194 pull_request = PullRequestModel().create(
1191 1195 created_by=self._rhodecode_user.user_id,
1192 1196 source_repo=source_repo,
1193 1197 source_ref=source_ref,
1194 1198 target_repo=target_repo,
1195 1199 target_ref=target_ref,
1196 1200 revisions=commit_ids,
1197 1201 common_ancestor_id=common_ancestor_id,
1198 1202 reviewers=reviewers,
1199 1203 observers=observers,
1200 1204 title=pullrequest_title,
1201 1205 description=description,
1202 1206 description_renderer=description_renderer,
1203 1207 reviewer_data=reviewer_rules,
1204 1208 auth_user=self._rhodecode_user
1205 1209 )
1206 1210 Session().commit()
1207 1211
1208 1212 h.flash(_('Successfully opened new pull request'),
1209 1213 category='success')
1210 1214 except Exception:
1211 1215 msg = _('Error occurred during creation of this pull request.')
1212 1216 log.exception(msg)
1213 1217 h.flash(msg, category='error')
1214 1218
1215 1219 # copy the args back to redirect
1216 1220 org_query = self.request.GET.mixed()
1217 1221 raise HTTPFound(
1218 1222 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1219 1223 _query=org_query))
1220 1224
1221 1225 raise HTTPFound(
1222 1226 h.route_path('pullrequest_show', repo_name=target_repo,
1223 1227 pull_request_id=pull_request.pull_request_id))
1224 1228
1225 1229 @LoginRequired()
1226 1230 @NotAnonymous()
1227 1231 @HasRepoPermissionAnyDecorator(
1228 1232 'repository.read', 'repository.write', 'repository.admin')
1229 1233 @CSRFRequired()
1230 1234 @view_config(
1231 1235 route_name='pullrequest_update', request_method='POST',
1232 1236 renderer='json_ext')
1233 1237 def pull_request_update(self):
1234 1238 pull_request = PullRequest.get_or_404(
1235 1239 self.request.matchdict['pull_request_id'])
1236 1240 _ = self.request.translate
1237 1241
1238 1242 c = self.load_default_context()
1239 1243 redirect_url = None
1240 1244
1241 1245 if pull_request.is_closed():
1242 1246 log.debug('update: forbidden because pull request is closed')
1243 1247 msg = _(u'Cannot update closed pull requests.')
1244 1248 h.flash(msg, category='error')
1245 1249 return {'response': True,
1246 1250 'redirect_url': redirect_url}
1247 1251
1248 1252 is_state_changing = pull_request.is_state_changing()
1249 1253 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
1250 1254
1251 1255 # only owner or admin can update it
1252 1256 allowed_to_update = PullRequestModel().check_user_update(
1253 1257 pull_request, self._rhodecode_user)
1254 1258
1255 1259 if allowed_to_update:
1256 1260 controls = peppercorn.parse(self.request.POST.items())
1257 1261 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1258 1262
1259 1263 if 'review_members' in controls:
1260 1264 self._update_reviewers(
1261 1265 c,
1262 1266 pull_request, controls['review_members'],
1263 1267 pull_request.reviewer_data,
1264 1268 PullRequestReviewers.ROLE_REVIEWER)
1265 1269 elif 'observer_members' in controls:
1266 1270 self._update_reviewers(
1267 1271 c,
1268 1272 pull_request, controls['observer_members'],
1269 1273 pull_request.reviewer_data,
1270 1274 PullRequestReviewers.ROLE_OBSERVER)
1271 1275 elif str2bool(self.request.POST.get('update_commits', 'false')):
1272 1276 if is_state_changing:
1273 1277 log.debug('commits update: forbidden because pull request is in state %s',
1274 1278 pull_request.pull_request_state)
1275 1279 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1276 1280 u'Current state is: `{}`').format(
1277 1281 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1278 1282 h.flash(msg, category='error')
1279 1283 return {'response': True,
1280 1284 'redirect_url': redirect_url}
1281 1285
1282 1286 self._update_commits(c, pull_request)
1283 1287 if force_refresh:
1284 1288 redirect_url = h.route_path(
1285 1289 'pullrequest_show', repo_name=self.db_repo_name,
1286 1290 pull_request_id=pull_request.pull_request_id,
1287 1291 _query={"force_refresh": 1})
1288 1292 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1289 1293 self._edit_pull_request(pull_request)
1290 1294 else:
1291 1295 log.error('Unhandled update data.')
1292 1296 raise HTTPBadRequest()
1293 1297
1294 1298 return {'response': True,
1295 1299 'redirect_url': redirect_url}
1296 1300 raise HTTPForbidden()
1297 1301
1298 1302 def _edit_pull_request(self, pull_request):
1299 1303 """
1300 1304 Edit title and description
1301 1305 """
1302 1306 _ = self.request.translate
1303 1307
1304 1308 try:
1305 1309 PullRequestModel().edit(
1306 1310 pull_request,
1307 1311 self.request.POST.get('title'),
1308 1312 self.request.POST.get('description'),
1309 1313 self.request.POST.get('description_renderer'),
1310 1314 self._rhodecode_user)
1311 1315 except ValueError:
1312 1316 msg = _(u'Cannot update closed pull requests.')
1313 1317 h.flash(msg, category='error')
1314 1318 return
1315 1319 else:
1316 1320 Session().commit()
1317 1321
1318 1322 msg = _(u'Pull request title & description updated.')
1319 1323 h.flash(msg, category='success')
1320 1324 return
1321 1325
1322 1326 def _update_commits(self, c, pull_request):
1323 1327 _ = self.request.translate
1324 1328
1325 1329 with pull_request.set_state(PullRequest.STATE_UPDATING):
1326 1330 resp = PullRequestModel().update_commits(
1327 1331 pull_request, self._rhodecode_db_user)
1328 1332
1329 1333 if resp.executed:
1330 1334
1331 1335 if resp.target_changed and resp.source_changed:
1332 1336 changed = 'target and source repositories'
1333 1337 elif resp.target_changed and not resp.source_changed:
1334 1338 changed = 'target repository'
1335 1339 elif not resp.target_changed and resp.source_changed:
1336 1340 changed = 'source repository'
1337 1341 else:
1338 1342 changed = 'nothing'
1339 1343
1340 1344 msg = _(u'Pull request updated to "{source_commit_id}" with '
1341 1345 u'{count_added} added, {count_removed} removed commits. '
1342 1346 u'Source of changes: {change_source}.')
1343 1347 msg = msg.format(
1344 1348 source_commit_id=pull_request.source_ref_parts.commit_id,
1345 1349 count_added=len(resp.changes.added),
1346 1350 count_removed=len(resp.changes.removed),
1347 1351 change_source=changed)
1348 1352 h.flash(msg, category='success')
1349 1353 channelstream.pr_update_channelstream_push(
1350 1354 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1351 1355 else:
1352 1356 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1353 1357 warning_reasons = [
1354 1358 UpdateFailureReason.NO_CHANGE,
1355 1359 UpdateFailureReason.WRONG_REF_TYPE,
1356 1360 ]
1357 1361 category = 'warning' if resp.reason in warning_reasons else 'error'
1358 1362 h.flash(msg, category=category)
1359 1363
1360 1364 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1361 1365 _ = self.request.translate
1362 1366
1363 1367 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1364 1368 PullRequestModel().get_reviewer_functions()
1365 1369
1366 1370 if role == PullRequestReviewers.ROLE_REVIEWER:
1367 1371 try:
1368 1372 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1369 1373 except ValueError as e:
1370 1374 log.error('Reviewers Validation: {}'.format(e))
1371 1375 h.flash(e, category='error')
1372 1376 return
1373 1377
1374 1378 old_calculated_status = pull_request.calculated_review_status()
1375 1379 PullRequestModel().update_reviewers(
1376 1380 pull_request, reviewers, self._rhodecode_db_user)
1377 1381
1378 1382 Session().commit()
1379 1383
1380 1384 msg = _('Pull request reviewers updated.')
1381 1385 h.flash(msg, category='success')
1382 1386 channelstream.pr_update_channelstream_push(
1383 1387 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1384 1388
1385 1389 # trigger status changed if change in reviewers changes the status
1386 1390 calculated_status = pull_request.calculated_review_status()
1387 1391 if old_calculated_status != calculated_status:
1388 1392 PullRequestModel().trigger_pull_request_hook(
1389 1393 pull_request, self._rhodecode_user, 'review_status_change',
1390 1394 data={'status': calculated_status})
1391 1395
1392 1396 elif role == PullRequestReviewers.ROLE_OBSERVER:
1393 1397 try:
1394 1398 observers = validate_observers(review_members, reviewer_rules)
1395 1399 except ValueError as e:
1396 1400 log.error('Observers Validation: {}'.format(e))
1397 1401 h.flash(e, category='error')
1398 1402 return
1399 1403
1400 1404 PullRequestModel().update_observers(
1401 1405 pull_request, observers, self._rhodecode_db_user)
1402 1406
1403 1407 Session().commit()
1404 1408 msg = _('Pull request observers updated.')
1405 1409 h.flash(msg, category='success')
1406 1410 channelstream.pr_update_channelstream_push(
1407 1411 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1408 1412
1409 1413 @LoginRequired()
1410 1414 @NotAnonymous()
1411 1415 @HasRepoPermissionAnyDecorator(
1412 1416 'repository.read', 'repository.write', 'repository.admin')
1413 1417 @CSRFRequired()
1414 1418 @view_config(
1415 1419 route_name='pullrequest_merge', request_method='POST',
1416 1420 renderer='json_ext')
1417 1421 def pull_request_merge(self):
1418 1422 """
1419 1423 Merge will perform a server-side merge of the specified
1420 1424 pull request, if the pull request is approved and mergeable.
1421 1425 After successful merging, the pull request is automatically
1422 1426 closed, with a relevant comment.
1423 1427 """
1424 1428 pull_request = PullRequest.get_or_404(
1425 1429 self.request.matchdict['pull_request_id'])
1426 1430 _ = self.request.translate
1427 1431
1428 1432 if pull_request.is_state_changing():
1429 1433 log.debug('show: forbidden because pull request is in state %s',
1430 1434 pull_request.pull_request_state)
1431 1435 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1432 1436 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1433 1437 pull_request.pull_request_state)
1434 1438 h.flash(msg, category='error')
1435 1439 raise HTTPFound(
1436 1440 h.route_path('pullrequest_show',
1437 1441 repo_name=pull_request.target_repo.repo_name,
1438 1442 pull_request_id=pull_request.pull_request_id))
1439 1443
1440 1444 self.load_default_context()
1441 1445
1442 1446 with pull_request.set_state(PullRequest.STATE_UPDATING):
1443 1447 check = MergeCheck.validate(
1444 1448 pull_request, auth_user=self._rhodecode_user,
1445 1449 translator=self.request.translate)
1446 1450 merge_possible = not check.failed
1447 1451
1448 1452 for err_type, error_msg in check.errors:
1449 1453 h.flash(error_msg, category=err_type)
1450 1454
1451 1455 if merge_possible:
1452 1456 log.debug("Pre-conditions checked, trying to merge.")
1453 1457 extras = vcs_operation_context(
1454 1458 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1455 1459 username=self._rhodecode_db_user.username, action='push',
1456 1460 scm=pull_request.target_repo.repo_type)
1457 1461 with pull_request.set_state(PullRequest.STATE_UPDATING):
1458 1462 self._merge_pull_request(
1459 1463 pull_request, self._rhodecode_db_user, extras)
1460 1464 else:
1461 1465 log.debug("Pre-conditions failed, NOT merging.")
1462 1466
1463 1467 raise HTTPFound(
1464 1468 h.route_path('pullrequest_show',
1465 1469 repo_name=pull_request.target_repo.repo_name,
1466 1470 pull_request_id=pull_request.pull_request_id))
1467 1471
1468 1472 def _merge_pull_request(self, pull_request, user, extras):
1469 1473 _ = self.request.translate
1470 1474 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1471 1475
1472 1476 if merge_resp.executed:
1473 1477 log.debug("The merge was successful, closing the pull request.")
1474 1478 PullRequestModel().close_pull_request(
1475 1479 pull_request.pull_request_id, user)
1476 1480 Session().commit()
1477 1481 msg = _('Pull request was successfully merged and closed.')
1478 1482 h.flash(msg, category='success')
1479 1483 else:
1480 1484 log.debug(
1481 1485 "The merge was not successful. Merge response: %s", merge_resp)
1482 1486 msg = merge_resp.merge_status_message
1483 1487 h.flash(msg, category='error')
1484 1488
1485 1489 @LoginRequired()
1486 1490 @NotAnonymous()
1487 1491 @HasRepoPermissionAnyDecorator(
1488 1492 'repository.read', 'repository.write', 'repository.admin')
1489 1493 @CSRFRequired()
1490 1494 @view_config(
1491 1495 route_name='pullrequest_delete', request_method='POST',
1492 1496 renderer='json_ext')
1493 1497 def pull_request_delete(self):
1494 1498 _ = self.request.translate
1495 1499
1496 1500 pull_request = PullRequest.get_or_404(
1497 1501 self.request.matchdict['pull_request_id'])
1498 1502 self.load_default_context()
1499 1503
1500 1504 pr_closed = pull_request.is_closed()
1501 1505 allowed_to_delete = PullRequestModel().check_user_delete(
1502 1506 pull_request, self._rhodecode_user) and not pr_closed
1503 1507
1504 1508 # only owner can delete it !
1505 1509 if allowed_to_delete:
1506 1510 PullRequestModel().delete(pull_request, self._rhodecode_user)
1507 1511 Session().commit()
1508 1512 h.flash(_('Successfully deleted pull request'),
1509 1513 category='success')
1510 1514 raise HTTPFound(h.route_path('pullrequest_show_all',
1511 1515 repo_name=self.db_repo_name))
1512 1516
1513 1517 log.warning('user %s tried to delete pull request without access',
1514 1518 self._rhodecode_user)
1515 1519 raise HTTPNotFound()
1516 1520
1517 1521 @LoginRequired()
1518 1522 @NotAnonymous()
1519 1523 @HasRepoPermissionAnyDecorator(
1520 1524 'repository.read', 'repository.write', 'repository.admin')
1521 1525 @CSRFRequired()
1522 1526 @view_config(
1523 1527 route_name='pullrequest_comment_create', request_method='POST',
1524 1528 renderer='json_ext')
1525 1529 def pull_request_comment_create(self):
1526 1530 _ = self.request.translate
1527 1531
1528 1532 pull_request = PullRequest.get_or_404(
1529 1533 self.request.matchdict['pull_request_id'])
1530 1534 pull_request_id = pull_request.pull_request_id
1531 1535
1532 1536 if pull_request.is_closed():
1533 1537 log.debug('comment: forbidden because pull request is closed')
1534 1538 raise HTTPForbidden()
1535 1539
1536 1540 allowed_to_comment = PullRequestModel().check_user_comment(
1537 1541 pull_request, self._rhodecode_user)
1538 1542 if not allowed_to_comment:
1539 1543 log.debug('comment: forbidden because pull request is from forbidden repo')
1540 1544 raise HTTPForbidden()
1541 1545
1542 1546 c = self.load_default_context()
1543 1547
1544 1548 status = self.request.POST.get('changeset_status', None)
1545 1549 text = self.request.POST.get('text')
1546 1550 comment_type = self.request.POST.get('comment_type')
1551 is_draft = str2bool(self.request.POST.get('draft'))
1547 1552 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1548 1553 close_pull_request = self.request.POST.get('close_pull_request')
1549 1554
1550 1555 # the logic here should work like following, if we submit close
1551 1556 # pr comment, use `close_pull_request_with_comment` function
1552 1557 # else handle regular comment logic
1553 1558
1554 1559 if close_pull_request:
1555 1560 # only owner or admin or person with write permissions
1556 1561 allowed_to_close = PullRequestModel().check_user_update(
1557 1562 pull_request, self._rhodecode_user)
1558 1563 if not allowed_to_close:
1559 1564 log.debug('comment: forbidden because not allowed to close '
1560 1565 'pull request %s', pull_request_id)
1561 1566 raise HTTPForbidden()
1562 1567
1563 1568 # This also triggers `review_status_change`
1564 1569 comment, status = PullRequestModel().close_pull_request_with_comment(
1565 1570 pull_request, self._rhodecode_user, self.db_repo, message=text,
1566 1571 auth_user=self._rhodecode_user)
1567 1572 Session().flush()
1568 1573 is_inline = comment.is_inline
1569 1574
1570 1575 PullRequestModel().trigger_pull_request_hook(
1571 1576 pull_request, self._rhodecode_user, 'comment',
1572 1577 data={'comment': comment})
1573 1578
1574 1579 else:
1575 1580 # regular comment case, could be inline, or one with status.
1576 1581 # for that one we check also permissions
1577
1582 # Additionally ENSURE if somehow draft is sent we're then unable to change status
1578 1583 allowed_to_change_status = PullRequestModel().check_user_change_status(
1579 pull_request, self._rhodecode_user)
1584 pull_request, self._rhodecode_user) and not is_draft
1580 1585
1581 1586 if status and allowed_to_change_status:
1582 1587 message = (_('Status change %(transition_icon)s %(status)s')
1583 1588 % {'transition_icon': '>',
1584 1589 'status': ChangesetStatus.get_status_lbl(status)})
1585 1590 text = text or message
1586 1591
1587 1592 comment = CommentsModel().create(
1588 1593 text=text,
1589 1594 repo=self.db_repo.repo_id,
1590 1595 user=self._rhodecode_user.user_id,
1591 1596 pull_request=pull_request,
1592 1597 f_path=self.request.POST.get('f_path'),
1593 1598 line_no=self.request.POST.get('line'),
1594 1599 status_change=(ChangesetStatus.get_status_lbl(status)
1595 1600 if status and allowed_to_change_status else None),
1596 1601 status_change_type=(status
1597 1602 if status and allowed_to_change_status else None),
1598 1603 comment_type=comment_type,
1604 is_draft=is_draft,
1599 1605 resolves_comment_id=resolves_comment_id,
1600 auth_user=self._rhodecode_user
1606 auth_user=self._rhodecode_user,
1607 send_email=not is_draft, # skip notification for draft comments
1601 1608 )
1602 1609 is_inline = comment.is_inline
1603 1610
1604 1611 if allowed_to_change_status:
1605 1612 # calculate old status before we change it
1606 1613 old_calculated_status = pull_request.calculated_review_status()
1607 1614
1608 1615 # get status if set !
1609 1616 if status:
1610 1617 ChangesetStatusModel().set_status(
1611 1618 self.db_repo.repo_id,
1612 1619 status,
1613 1620 self._rhodecode_user.user_id,
1614 1621 comment,
1615 1622 pull_request=pull_request
1616 1623 )
1617 1624
1618 1625 Session().flush()
1619 1626 # this is somehow required to get access to some relationship
1620 1627 # loaded on comment
1621 1628 Session().refresh(comment)
1622 1629
1623 1630 PullRequestModel().trigger_pull_request_hook(
1624 1631 pull_request, self._rhodecode_user, 'comment',
1625 1632 data={'comment': comment})
1626 1633
1627 1634 # we now calculate the status of pull request, and based on that
1628 1635 # calculation we set the commits status
1629 1636 calculated_status = pull_request.calculated_review_status()
1630 1637 if old_calculated_status != calculated_status:
1631 1638 PullRequestModel().trigger_pull_request_hook(
1632 1639 pull_request, self._rhodecode_user, 'review_status_change',
1633 1640 data={'status': calculated_status})
1634 1641
1635 1642 Session().commit()
1636 1643
1637 1644 data = {
1638 1645 'target_id': h.safeid(h.safe_unicode(
1639 1646 self.request.POST.get('f_path'))),
1640 1647 }
1648
1641 1649 if comment:
1642 1650 c.co = comment
1643 1651 c.at_version_num = None
1644 1652 rendered_comment = render(
1645 1653 'rhodecode:templates/changeset/changeset_comment_block.mako',
1646 1654 self._get_template_context(c), self.request)
1647 1655
1648 1656 data.update(comment.get_dict())
1649 1657 data.update({'rendered_text': rendered_comment})
1650 1658
1659 # skip channelstream for draft comments
1660 if not is_draft:
1651 1661 comment_broadcast_channel = channelstream.comment_channel(
1652 1662 self.db_repo_name, pull_request_obj=pull_request)
1653 1663
1654 1664 comment_data = data
1655 1665 comment_type = 'inline' if is_inline else 'general'
1656 1666 channelstream.comment_channelstream_push(
1657 1667 self.request, comment_broadcast_channel, self._rhodecode_user,
1658 1668 _('posted a new {} comment').format(comment_type),
1659 1669 comment_data=comment_data)
1660 1670
1661 1671 return data
1662 1672
1663 1673 @LoginRequired()
1664 1674 @NotAnonymous()
1665 1675 @HasRepoPermissionAnyDecorator(
1666 1676 'repository.read', 'repository.write', 'repository.admin')
1667 1677 @CSRFRequired()
1668 1678 @view_config(
1669 1679 route_name='pullrequest_comment_delete', request_method='POST',
1670 1680 renderer='json_ext')
1671 1681 def pull_request_comment_delete(self):
1672 1682 pull_request = PullRequest.get_or_404(
1673 1683 self.request.matchdict['pull_request_id'])
1674 1684
1675 1685 comment = ChangesetComment.get_or_404(
1676 1686 self.request.matchdict['comment_id'])
1677 1687 comment_id = comment.comment_id
1678 1688
1679 1689 if comment.immutable:
1680 1690 # don't allow deleting comments that are immutable
1681 1691 raise HTTPForbidden()
1682 1692
1683 1693 if pull_request.is_closed():
1684 1694 log.debug('comment: forbidden because pull request is closed')
1685 1695 raise HTTPForbidden()
1686 1696
1687 1697 if not comment:
1688 1698 log.debug('Comment with id:%s not found, skipping', comment_id)
1689 1699 # comment already deleted in another call probably
1690 1700 return True
1691 1701
1692 1702 if comment.pull_request.is_closed():
1693 1703 # don't allow deleting comments on closed pull request
1694 1704 raise HTTPForbidden()
1695 1705
1696 1706 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1697 1707 super_admin = h.HasPermissionAny('hg.admin')()
1698 1708 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1699 1709 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1700 1710 comment_repo_admin = is_repo_admin and is_repo_comment
1701 1711
1702 1712 if super_admin or comment_owner or comment_repo_admin:
1703 1713 old_calculated_status = comment.pull_request.calculated_review_status()
1704 1714 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1705 1715 Session().commit()
1706 1716 calculated_status = comment.pull_request.calculated_review_status()
1707 1717 if old_calculated_status != calculated_status:
1708 1718 PullRequestModel().trigger_pull_request_hook(
1709 1719 comment.pull_request, self._rhodecode_user, 'review_status_change',
1710 1720 data={'status': calculated_status})
1711 1721 return True
1712 1722 else:
1713 1723 log.warning('No permissions for user %s to delete comment_id: %s',
1714 1724 self._rhodecode_db_user, comment_id)
1715 1725 raise HTTPNotFound()
1716 1726
1717 1727 @LoginRequired()
1718 1728 @NotAnonymous()
1719 1729 @HasRepoPermissionAnyDecorator(
1720 1730 'repository.read', 'repository.write', 'repository.admin')
1721 1731 @CSRFRequired()
1722 1732 @view_config(
1723 1733 route_name='pullrequest_comment_edit', request_method='POST',
1724 1734 renderer='json_ext')
1725 1735 def pull_request_comment_edit(self):
1726 1736 self.load_default_context()
1727 1737
1728 1738 pull_request = PullRequest.get_or_404(
1729 1739 self.request.matchdict['pull_request_id']
1730 1740 )
1731 1741 comment = ChangesetComment.get_or_404(
1732 1742 self.request.matchdict['comment_id']
1733 1743 )
1734 1744 comment_id = comment.comment_id
1735 1745
1736 1746 if comment.immutable:
1737 1747 # don't allow deleting comments that are immutable
1738 1748 raise HTTPForbidden()
1739 1749
1740 1750 if pull_request.is_closed():
1741 1751 log.debug('comment: forbidden because pull request is closed')
1742 1752 raise HTTPForbidden()
1743 1753
1744 1754 if not comment:
1745 1755 log.debug('Comment with id:%s not found, skipping', comment_id)
1746 1756 # comment already deleted in another call probably
1747 1757 return True
1748 1758
1749 1759 if comment.pull_request.is_closed():
1750 1760 # don't allow deleting comments on closed pull request
1751 1761 raise HTTPForbidden()
1752 1762
1753 1763 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1754 1764 super_admin = h.HasPermissionAny('hg.admin')()
1755 1765 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1756 1766 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1757 1767 comment_repo_admin = is_repo_admin and is_repo_comment
1758 1768
1759 1769 if super_admin or comment_owner or comment_repo_admin:
1760 1770 text = self.request.POST.get('text')
1761 1771 version = self.request.POST.get('version')
1762 1772 if text == comment.text:
1763 1773 log.warning(
1764 1774 'Comment(PR): '
1765 1775 'Trying to create new version '
1766 1776 'with the same comment body {}'.format(
1767 1777 comment_id,
1768 1778 )
1769 1779 )
1770 1780 raise HTTPNotFound()
1771 1781
1772 1782 if version.isdigit():
1773 1783 version = int(version)
1774 1784 else:
1775 1785 log.warning(
1776 1786 'Comment(PR): Wrong version type {} {} '
1777 1787 'for comment {}'.format(
1778 1788 version,
1779 1789 type(version),
1780 1790 comment_id,
1781 1791 )
1782 1792 )
1783 1793 raise HTTPNotFound()
1784 1794
1785 1795 try:
1786 1796 comment_history = CommentsModel().edit(
1787 1797 comment_id=comment_id,
1788 1798 text=text,
1789 1799 auth_user=self._rhodecode_user,
1790 1800 version=version,
1791 1801 )
1792 1802 except CommentVersionMismatch:
1793 1803 raise HTTPConflict()
1794 1804
1795 1805 if not comment_history:
1796 1806 raise HTTPNotFound()
1797 1807
1798 1808 Session().commit()
1799 1809
1800 1810 PullRequestModel().trigger_pull_request_hook(
1801 1811 pull_request, self._rhodecode_user, 'comment_edit',
1802 1812 data={'comment': comment})
1803 1813
1804 1814 return {
1805 1815 'comment_history_id': comment_history.comment_history_id,
1806 1816 'comment_id': comment.comment_id,
1807 1817 'comment_version': comment_history.version,
1808 1818 'comment_author_username': comment_history.author.username,
1809 1819 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1810 1820 'comment_created_on': h.age_component(comment_history.created_on,
1811 1821 time_is_local=True),
1812 1822 }
1813 1823 else:
1814 1824 log.warning('No permissions for user %s to edit comment_id: %s',
1815 1825 self._rhodecode_db_user, comment_id)
1816 1826 raise HTTPNotFound()
@@ -1,821 +1,843 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 false,
40 41 ChangesetComment,
41 42 User,
42 43 Notification,
43 44 PullRequest,
44 45 AttributeDict,
45 46 ChangesetCommentHistory,
46 47 )
47 48 from rhodecode.model.notification import NotificationModel
48 49 from rhodecode.model.meta import Session
49 50 from rhodecode.model.settings import VcsSettingsModel
50 51 from rhodecode.model.notification import EmailNotificationModel
51 52 from rhodecode.model.validation_schema.schemas import comment_schema
52 53
53 54
54 55 log = logging.getLogger(__name__)
55 56
56 57
57 58 class CommentsModel(BaseModel):
58 59
59 60 cls = ChangesetComment
60 61
61 62 DIFF_CONTEXT_BEFORE = 3
62 63 DIFF_CONTEXT_AFTER = 3
63 64
64 65 def __get_commit_comment(self, changeset_comment):
65 66 return self._get_instance(ChangesetComment, changeset_comment)
66 67
67 68 def __get_pull_request(self, pull_request):
68 69 return self._get_instance(PullRequest, pull_request)
69 70
70 71 def _extract_mentions(self, s):
71 72 user_objects = []
72 73 for username in extract_mentioned_users(s):
73 74 user_obj = User.get_by_username(username, case_insensitive=True)
74 75 if user_obj:
75 76 user_objects.append(user_obj)
76 77 return user_objects
77 78
78 79 def _get_renderer(self, global_renderer='rst', request=None):
79 80 request = request or get_current_request()
80 81
81 82 try:
82 83 global_renderer = request.call_context.visual.default_renderer
83 84 except AttributeError:
84 85 log.debug("Renderer not set, falling back "
85 86 "to default renderer '%s'", global_renderer)
86 87 except Exception:
87 88 log.error(traceback.format_exc())
88 89 return global_renderer
89 90
90 91 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 92 # group by versions, and count until, and display objects
92 93
93 94 comment_groups = collections.defaultdict(list)
94 95 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
95 96
96 97 def yield_comments(pos):
97 98 for co in comment_groups[pos]:
98 99 yield co
99 100
100 101 comment_versions = collections.defaultdict(
101 102 lambda: collections.defaultdict(list))
102 103 prev_prvid = -1
103 104 # fake last entry with None, to aggregate on "latest" version which
104 105 # doesn't have an pull_request_version_id
105 106 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
106 107 prvid = ver.pull_request_version_id
107 108 if prev_prvid == -1:
108 109 prev_prvid = prvid
109 110
110 111 for co in yield_comments(prvid):
111 112 comment_versions[prvid]['at'].append(co)
112 113
113 114 # save until
114 115 current = comment_versions[prvid]['at']
115 116 prev_until = comment_versions[prev_prvid]['until']
116 117 cur_until = prev_until + current
117 118 comment_versions[prvid]['until'].extend(cur_until)
118 119
119 120 # save outdated
120 121 if inline:
121 122 outdated = [x for x in cur_until
122 123 if x.outdated_at_version(show_version)]
123 124 else:
124 125 outdated = [x for x in cur_until
125 126 if x.older_than_version(show_version)]
126 127 display = [x for x in cur_until if x not in outdated]
127 128
128 129 comment_versions[prvid]['outdated'] = outdated
129 130 comment_versions[prvid]['display'] = display
130 131
131 132 prev_prvid = prvid
132 133
133 134 return comment_versions
134 135
135 136 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
136 137 qry = Session().query(ChangesetComment) \
137 138 .filter(ChangesetComment.repo == repo)
138 139
139 140 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
140 141 qry = qry.filter(ChangesetComment.comment_type == comment_type)
141 142
142 143 if user:
143 144 user = self._get_user(user)
144 145 if user:
145 146 qry = qry.filter(ChangesetComment.user_id == user.user_id)
146 147
147 148 if commit_id:
148 149 qry = qry.filter(ChangesetComment.revision == commit_id)
149 150
150 151 qry = qry.order_by(ChangesetComment.created_on)
151 152 return qry.all()
152 153
153 154 def get_repository_unresolved_todos(self, repo):
154 155 todos = Session().query(ChangesetComment) \
155 156 .filter(ChangesetComment.repo == repo) \
156 157 .filter(ChangesetComment.resolved_by == None) \
157 158 .filter(ChangesetComment.comment_type
158 159 == ChangesetComment.COMMENT_TYPE_TODO)
159 160 todos = todos.all()
160 161
161 162 return todos
162 163
163 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
164 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True, include_drafts=True):
164 165
165 166 todos = Session().query(ChangesetComment) \
166 167 .filter(ChangesetComment.pull_request == pull_request) \
167 168 .filter(ChangesetComment.resolved_by == None) \
168 169 .filter(ChangesetComment.comment_type
169 170 == ChangesetComment.COMMENT_TYPE_TODO)
170 171
172 if not include_drafts:
173 todos = todos.filter(ChangesetComment.draft == false())
174
171 175 if not show_outdated:
172 176 todos = todos.filter(
173 177 coalesce(ChangesetComment.display_state, '') !=
174 178 ChangesetComment.COMMENT_OUTDATED)
175 179
176 180 todos = todos.all()
177 181
178 182 return todos
179 183
180 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
184 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True, include_drafts=True):
181 185
182 186 todos = Session().query(ChangesetComment) \
183 187 .filter(ChangesetComment.pull_request == pull_request) \
184 188 .filter(ChangesetComment.resolved_by != None) \
185 189 .filter(ChangesetComment.comment_type
186 190 == ChangesetComment.COMMENT_TYPE_TODO)
187 191
192 if not include_drafts:
193 todos = todos.filter(ChangesetComment.draft == false())
194
188 195 if not show_outdated:
189 196 todos = todos.filter(
190 197 coalesce(ChangesetComment.display_state, '') !=
191 198 ChangesetComment.COMMENT_OUTDATED)
192 199
193 200 todos = todos.all()
194 201
195 202 return todos
196 203
197 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
204 def get_commit_unresolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
198 205
199 206 todos = Session().query(ChangesetComment) \
200 207 .filter(ChangesetComment.revision == commit_id) \
201 208 .filter(ChangesetComment.resolved_by == None) \
202 209 .filter(ChangesetComment.comment_type
203 210 == ChangesetComment.COMMENT_TYPE_TODO)
204 211
212 if not include_drafts:
213 todos = todos.filter(ChangesetComment.draft == false())
214
205 215 if not show_outdated:
206 216 todos = todos.filter(
207 217 coalesce(ChangesetComment.display_state, '') !=
208 218 ChangesetComment.COMMENT_OUTDATED)
209 219
210 220 todos = todos.all()
211 221
212 222 return todos
213 223
214 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
224 def get_commit_resolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
215 225
216 226 todos = Session().query(ChangesetComment) \
217 227 .filter(ChangesetComment.revision == commit_id) \
218 228 .filter(ChangesetComment.resolved_by != None) \
219 229 .filter(ChangesetComment.comment_type
220 230 == ChangesetComment.COMMENT_TYPE_TODO)
221 231
232 if not include_drafts:
233 todos = todos.filter(ChangesetComment.draft == false())
234
222 235 if not show_outdated:
223 236 todos = todos.filter(
224 237 coalesce(ChangesetComment.display_state, '') !=
225 238 ChangesetComment.COMMENT_OUTDATED)
226 239
227 240 todos = todos.all()
228 241
229 242 return todos
230 243
231 def get_commit_inline_comments(self, commit_id):
244 def get_commit_inline_comments(self, commit_id, include_drafts=True):
232 245 inline_comments = Session().query(ChangesetComment) \
233 246 .filter(ChangesetComment.line_no != None) \
234 247 .filter(ChangesetComment.f_path != None) \
235 248 .filter(ChangesetComment.revision == commit_id)
249
250 if not include_drafts:
251 inline_comments = inline_comments.filter(ChangesetComment.draft == false())
252
236 253 inline_comments = inline_comments.all()
237 254 return inline_comments
238 255
239 256 def _log_audit_action(self, action, action_data, auth_user, comment):
240 257 audit_logger.store(
241 258 action=action,
242 259 action_data=action_data,
243 260 user=auth_user,
244 261 repo=comment.repo)
245 262
246 263 def create(self, text, repo, user, commit_id=None, pull_request=None,
247 264 f_path=None, line_no=None, status_change=None,
248 status_change_type=None, comment_type=None,
265 status_change_type=None, comment_type=None, is_draft=False,
249 266 resolves_comment_id=None, closing_pr=False, send_email=True,
250 267 renderer=None, auth_user=None, extra_recipients=None):
251 268 """
252 269 Creates new comment for commit or pull request.
253 270 IF status_change is not none this comment is associated with a
254 271 status change of commit or commit associated with pull request
255 272
256 273 :param text:
257 274 :param repo:
258 275 :param user:
259 276 :param commit_id:
260 277 :param pull_request:
261 278 :param f_path:
262 279 :param line_no:
263 280 :param status_change: Label for status change
264 281 :param comment_type: Type of comment
282 :param is_draft: is comment a draft only
265 283 :param resolves_comment_id: id of comment which this one will resolve
266 284 :param status_change_type: type of status change
267 285 :param closing_pr:
268 286 :param send_email:
269 287 :param renderer: pick renderer for this comment
270 288 :param auth_user: current authenticated user calling this method
271 289 :param extra_recipients: list of extra users to be added to recipients
272 290 """
273 291
274 292 if not text:
275 293 log.warning('Missing text for comment, skipping...')
276 294 return
277 295 request = get_current_request()
278 296 _ = request.translate
279 297
280 298 if not renderer:
281 299 renderer = self._get_renderer(request=request)
282 300
283 301 repo = self._get_repo(repo)
284 302 user = self._get_user(user)
285 303 auth_user = auth_user or user
286 304
287 305 schema = comment_schema.CommentSchema()
288 306 validated_kwargs = schema.deserialize(dict(
289 307 comment_body=text,
290 308 comment_type=comment_type,
309 is_draft=is_draft,
291 310 comment_file=f_path,
292 311 comment_line=line_no,
293 312 renderer_type=renderer,
294 313 status_change=status_change_type,
295 314 resolves_comment_id=resolves_comment_id,
296 315 repo=repo.repo_id,
297 316 user=user.user_id,
298 317 ))
318 is_draft = validated_kwargs['is_draft']
299 319
300 320 comment = ChangesetComment()
301 321 comment.renderer = validated_kwargs['renderer_type']
302 322 comment.text = validated_kwargs['comment_body']
303 323 comment.f_path = validated_kwargs['comment_file']
304 324 comment.line_no = validated_kwargs['comment_line']
305 325 comment.comment_type = validated_kwargs['comment_type']
326 comment.draft = is_draft
306 327
307 328 comment.repo = repo
308 329 comment.author = user
309 330 resolved_comment = self.__get_commit_comment(
310 331 validated_kwargs['resolves_comment_id'])
311 332 # check if the comment actually belongs to this PR
312 333 if resolved_comment and resolved_comment.pull_request and \
313 334 resolved_comment.pull_request != pull_request:
314 335 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 336 resolved_comment)
316 337 # comment not bound to this pull request, forbid
317 338 resolved_comment = None
318 339
319 340 elif resolved_comment and resolved_comment.repo and \
320 341 resolved_comment.repo != repo:
321 342 log.warning('Comment tried to resolved unrelated todo comment: %s',
322 343 resolved_comment)
323 344 # comment not bound to this repo, forbid
324 345 resolved_comment = None
325 346
326 347 comment.resolved_comment = resolved_comment
327 348
328 349 pull_request_id = pull_request
329 350
330 351 commit_obj = None
331 352 pull_request_obj = None
332 353
333 354 if commit_id:
334 355 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
335 356 # do a lookup, so we don't pass something bad here
336 357 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
337 358 comment.revision = commit_obj.raw_id
338 359
339 360 elif pull_request_id:
340 361 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
341 362 pull_request_obj = self.__get_pull_request(pull_request_id)
342 363 comment.pull_request = pull_request_obj
343 364 else:
344 365 raise Exception('Please specify commit or pull_request_id')
345 366
346 367 Session().add(comment)
347 368 Session().flush()
348 369 kwargs = {
349 370 'user': user,
350 371 'renderer_type': renderer,
351 372 'repo_name': repo.repo_name,
352 373 'status_change': status_change,
353 374 'status_change_type': status_change_type,
354 375 'comment_body': text,
355 376 'comment_file': f_path,
356 377 'comment_line': line_no,
357 378 'comment_type': comment_type or 'note',
358 379 'comment_id': comment.comment_id
359 380 }
360 381
361 382 if commit_obj:
362 383 recipients = ChangesetComment.get_users(
363 384 revision=commit_obj.raw_id)
364 385 # add commit author if it's in RhodeCode system
365 386 cs_author = User.get_from_cs_author(commit_obj.author)
366 387 if not cs_author:
367 388 # use repo owner if we cannot extract the author correctly
368 389 cs_author = repo.user
369 390 recipients += [cs_author]
370 391
371 392 commit_comment_url = self.get_url(comment, request=request)
372 393 commit_comment_reply_url = self.get_url(
373 394 comment, request=request,
374 395 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
375 396
376 397 target_repo_url = h.link_to(
377 398 repo.repo_name,
378 399 h.route_url('repo_summary', repo_name=repo.repo_name))
379 400
380 401 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
381 402 commit_id=commit_id)
382 403
383 404 # commit specifics
384 405 kwargs.update({
385 406 'commit': commit_obj,
386 407 'commit_message': commit_obj.message,
387 408 'commit_target_repo_url': target_repo_url,
388 409 'commit_comment_url': commit_comment_url,
389 410 'commit_comment_reply_url': commit_comment_reply_url,
390 411 'commit_url': commit_url,
391 412 'thread_ids': [commit_url, commit_comment_url],
392 413 })
393 414
394 415 elif pull_request_obj:
395 416 # get the current participants of this pull request
396 417 recipients = ChangesetComment.get_users(
397 418 pull_request_id=pull_request_obj.pull_request_id)
398 419 # add pull request author
399 420 recipients += [pull_request_obj.author]
400 421
401 422 # add the reviewers to notification
402 423 recipients += [x.user for x in pull_request_obj.get_pull_request_reviewers()]
403 424
404 425 pr_target_repo = pull_request_obj.target_repo
405 426 pr_source_repo = pull_request_obj.source_repo
406 427
407 428 pr_comment_url = self.get_url(comment, request=request)
408 429 pr_comment_reply_url = self.get_url(
409 430 comment, request=request,
410 431 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
411 432
412 433 pr_url = h.route_url(
413 434 'pullrequest_show',
414 435 repo_name=pr_target_repo.repo_name,
415 436 pull_request_id=pull_request_obj.pull_request_id, )
416 437
417 438 # set some variables for email notification
418 439 pr_target_repo_url = h.route_url(
419 440 'repo_summary', repo_name=pr_target_repo.repo_name)
420 441
421 442 pr_source_repo_url = h.route_url(
422 443 'repo_summary', repo_name=pr_source_repo.repo_name)
423 444
424 445 # pull request specifics
425 446 kwargs.update({
426 447 'pull_request': pull_request_obj,
427 448 'pr_id': pull_request_obj.pull_request_id,
428 449 'pull_request_url': pr_url,
429 450 'pull_request_target_repo': pr_target_repo,
430 451 'pull_request_target_repo_url': pr_target_repo_url,
431 452 'pull_request_source_repo': pr_source_repo,
432 453 'pull_request_source_repo_url': pr_source_repo_url,
433 454 'pr_comment_url': pr_comment_url,
434 455 'pr_comment_reply_url': pr_comment_reply_url,
435 456 'pr_closing': closing_pr,
436 457 'thread_ids': [pr_url, pr_comment_url],
437 458 })
438 459
439 460 if send_email:
440 461 recipients += [self._get_user(u) for u in (extra_recipients or [])]
441 462 # pre-generate the subject for notification itself
442 463 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
443 464 notification_type, **kwargs)
444 465
445 466 mention_recipients = set(
446 467 self._extract_mentions(text)).difference(recipients)
447 468
448 469 # create notification objects, and emails
449 470 NotificationModel().create(
450 471 created_by=user,
451 472 notification_subject=subject,
452 473 notification_body=body_plaintext,
453 474 notification_type=notification_type,
454 475 recipients=recipients,
455 476 mention_recipients=mention_recipients,
456 477 email_kwargs=kwargs,
457 478 )
458 479
459 480 Session().flush()
460 481 if comment.pull_request:
461 482 action = 'repo.pull_request.comment.create'
462 483 else:
463 484 action = 'repo.commit.comment.create'
464 485
486 if not is_draft:
465 487 comment_data = comment.get_api_data()
466 488
467 489 self._log_audit_action(
468 490 action, {'data': comment_data}, auth_user, comment)
469 491
470 492 return comment
471 493
472 494 def edit(self, comment_id, text, auth_user, version):
473 495 """
474 496 Change existing comment for commit or pull request.
475 497
476 498 :param comment_id:
477 499 :param text:
478 500 :param auth_user: current authenticated user calling this method
479 501 :param version: last comment version
480 502 """
481 503 if not text:
482 504 log.warning('Missing text for comment, skipping...')
483 505 return
484 506
485 507 comment = ChangesetComment.get(comment_id)
486 508 old_comment_text = comment.text
487 509 comment.text = text
488 510 comment.modified_at = datetime.datetime.now()
489 511 version = safe_int(version)
490 512
491 513 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
492 514 # would return 3 here
493 515 comment_version = ChangesetCommentHistory.get_version(comment_id)
494 516
495 517 if isinstance(version, (int, long)) and (comment_version - version) != 1:
496 518 log.warning(
497 519 'Version mismatch comment_version {} submitted {}, skipping'.format(
498 520 comment_version-1, # -1 since note above
499 521 version
500 522 )
501 523 )
502 524 raise CommentVersionMismatch()
503 525
504 526 comment_history = ChangesetCommentHistory()
505 527 comment_history.comment_id = comment_id
506 528 comment_history.version = comment_version
507 529 comment_history.created_by_user_id = auth_user.user_id
508 530 comment_history.text = old_comment_text
509 531 # TODO add email notification
510 532 Session().add(comment_history)
511 533 Session().add(comment)
512 534 Session().flush()
513 535
514 536 if comment.pull_request:
515 537 action = 'repo.pull_request.comment.edit'
516 538 else:
517 539 action = 'repo.commit.comment.edit'
518 540
519 541 comment_data = comment.get_api_data()
520 542 comment_data['old_comment_text'] = old_comment_text
521 543 self._log_audit_action(
522 544 action, {'data': comment_data}, auth_user, comment)
523 545
524 546 return comment_history
525 547
526 548 def delete(self, comment, auth_user):
527 549 """
528 550 Deletes given comment
529 551 """
530 552 comment = self.__get_commit_comment(comment)
531 553 old_data = comment.get_api_data()
532 554 Session().delete(comment)
533 555
534 556 if comment.pull_request:
535 557 action = 'repo.pull_request.comment.delete'
536 558 else:
537 559 action = 'repo.commit.comment.delete'
538 560
539 561 self._log_audit_action(
540 562 action, {'old_data': old_data}, auth_user, comment)
541 563
542 564 return comment
543 565
544 566 def get_all_comments(self, repo_id, revision=None, pull_request=None, count_only=False):
545 567 q = ChangesetComment.query()\
546 568 .filter(ChangesetComment.repo_id == repo_id)
547 569 if revision:
548 570 q = q.filter(ChangesetComment.revision == revision)
549 571 elif pull_request:
550 572 pull_request = self.__get_pull_request(pull_request)
551 573 q = q.filter(ChangesetComment.pull_request_id == pull_request.pull_request_id)
552 574 else:
553 575 raise Exception('Please specify commit or pull_request')
554 576 q = q.order_by(ChangesetComment.created_on)
555 577 if count_only:
556 578 return q.count()
557 579
558 580 return q.all()
559 581
560 582 def get_url(self, comment, request=None, permalink=False, anchor=None):
561 583 if not request:
562 584 request = get_current_request()
563 585
564 586 comment = self.__get_commit_comment(comment)
565 587 if anchor is None:
566 588 anchor = 'comment-{}'.format(comment.comment_id)
567 589
568 590 if comment.pull_request:
569 591 pull_request = comment.pull_request
570 592 if permalink:
571 593 return request.route_url(
572 594 'pull_requests_global',
573 595 pull_request_id=pull_request.pull_request_id,
574 596 _anchor=anchor)
575 597 else:
576 598 return request.route_url(
577 599 'pullrequest_show',
578 600 repo_name=safe_str(pull_request.target_repo.repo_name),
579 601 pull_request_id=pull_request.pull_request_id,
580 602 _anchor=anchor)
581 603
582 604 else:
583 605 repo = comment.repo
584 606 commit_id = comment.revision
585 607
586 608 if permalink:
587 609 return request.route_url(
588 610 'repo_commit', repo_name=safe_str(repo.repo_id),
589 611 commit_id=commit_id,
590 612 _anchor=anchor)
591 613
592 614 else:
593 615 return request.route_url(
594 616 'repo_commit', repo_name=safe_str(repo.repo_name),
595 617 commit_id=commit_id,
596 618 _anchor=anchor)
597 619
598 620 def get_comments(self, repo_id, revision=None, pull_request=None):
599 621 """
600 622 Gets main comments based on revision or pull_request_id
601 623
602 624 :param repo_id:
603 625 :param revision:
604 626 :param pull_request:
605 627 """
606 628
607 629 q = ChangesetComment.query()\
608 630 .filter(ChangesetComment.repo_id == repo_id)\
609 631 .filter(ChangesetComment.line_no == None)\
610 632 .filter(ChangesetComment.f_path == None)
611 633 if revision:
612 634 q = q.filter(ChangesetComment.revision == revision)
613 635 elif pull_request:
614 636 pull_request = self.__get_pull_request(pull_request)
615 637 q = q.filter(ChangesetComment.pull_request == pull_request)
616 638 else:
617 639 raise Exception('Please specify commit or pull_request')
618 640 q = q.order_by(ChangesetComment.created_on)
619 641 return q.all()
620 642
621 643 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
622 644 q = self._get_inline_comments_query(repo_id, revision, pull_request)
623 645 return self._group_comments_by_path_and_line_number(q)
624 646
625 647 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
626 648 version=None):
627 649 inline_comms = []
628 650 for fname, per_line_comments in inline_comments.iteritems():
629 651 for lno, comments in per_line_comments.iteritems():
630 652 for comm in comments:
631 653 if not comm.outdated_at_version(version) and skip_outdated:
632 654 inline_comms.append(comm)
633 655
634 656 return inline_comms
635 657
636 658 def get_outdated_comments(self, repo_id, pull_request):
637 659 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
638 660 # of a pull request.
639 661 q = self._all_inline_comments_of_pull_request(pull_request)
640 662 q = q.filter(
641 663 ChangesetComment.display_state ==
642 664 ChangesetComment.COMMENT_OUTDATED
643 665 ).order_by(ChangesetComment.comment_id.asc())
644 666
645 667 return self._group_comments_by_path_and_line_number(q)
646 668
647 669 def _get_inline_comments_query(self, repo_id, revision, pull_request):
648 670 # TODO: johbo: Split this into two methods: One for PR and one for
649 671 # commit.
650 672 if revision:
651 673 q = Session().query(ChangesetComment).filter(
652 674 ChangesetComment.repo_id == repo_id,
653 675 ChangesetComment.line_no != null(),
654 676 ChangesetComment.f_path != null(),
655 677 ChangesetComment.revision == revision)
656 678
657 679 elif pull_request:
658 680 pull_request = self.__get_pull_request(pull_request)
659 681 if not CommentsModel.use_outdated_comments(pull_request):
660 682 q = self._visible_inline_comments_of_pull_request(pull_request)
661 683 else:
662 684 q = self._all_inline_comments_of_pull_request(pull_request)
663 685
664 686 else:
665 687 raise Exception('Please specify commit or pull_request_id')
666 688 q = q.order_by(ChangesetComment.comment_id.asc())
667 689 return q
668 690
669 691 def _group_comments_by_path_and_line_number(self, q):
670 692 comments = q.all()
671 693 paths = collections.defaultdict(lambda: collections.defaultdict(list))
672 694 for co in comments:
673 695 paths[co.f_path][co.line_no].append(co)
674 696 return paths
675 697
676 698 @classmethod
677 699 def needed_extra_diff_context(cls):
678 700 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
679 701
680 702 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
681 703 if not CommentsModel.use_outdated_comments(pull_request):
682 704 return
683 705
684 706 comments = self._visible_inline_comments_of_pull_request(pull_request)
685 707 comments_to_outdate = comments.all()
686 708
687 709 for comment in comments_to_outdate:
688 710 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
689 711
690 712 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
691 713 diff_line = _parse_comment_line_number(comment.line_no)
692 714
693 715 try:
694 716 old_context = old_diff_proc.get_context_of_line(
695 717 path=comment.f_path, diff_line=diff_line)
696 718 new_context = new_diff_proc.get_context_of_line(
697 719 path=comment.f_path, diff_line=diff_line)
698 720 except (diffs.LineNotInDiffException,
699 721 diffs.FileNotInDiffException):
700 722 comment.display_state = ChangesetComment.COMMENT_OUTDATED
701 723 return
702 724
703 725 if old_context == new_context:
704 726 return
705 727
706 728 if self._should_relocate_diff_line(diff_line):
707 729 new_diff_lines = new_diff_proc.find_context(
708 730 path=comment.f_path, context=old_context,
709 731 offset=self.DIFF_CONTEXT_BEFORE)
710 732 if not new_diff_lines:
711 733 comment.display_state = ChangesetComment.COMMENT_OUTDATED
712 734 else:
713 735 new_diff_line = self._choose_closest_diff_line(
714 736 diff_line, new_diff_lines)
715 737 comment.line_no = _diff_to_comment_line_number(new_diff_line)
716 738 else:
717 739 comment.display_state = ChangesetComment.COMMENT_OUTDATED
718 740
719 741 def _should_relocate_diff_line(self, diff_line):
720 742 """
721 743 Checks if relocation shall be tried for the given `diff_line`.
722 744
723 745 If a comment points into the first lines, then we can have a situation
724 746 that after an update another line has been added on top. In this case
725 747 we would find the context still and move the comment around. This
726 748 would be wrong.
727 749 """
728 750 should_relocate = (
729 751 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
730 752 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
731 753 return should_relocate
732 754
733 755 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
734 756 candidate = new_diff_lines[0]
735 757 best_delta = _diff_line_delta(diff_line, candidate)
736 758 for new_diff_line in new_diff_lines[1:]:
737 759 delta = _diff_line_delta(diff_line, new_diff_line)
738 760 if delta < best_delta:
739 761 candidate = new_diff_line
740 762 best_delta = delta
741 763 return candidate
742 764
743 765 def _visible_inline_comments_of_pull_request(self, pull_request):
744 766 comments = self._all_inline_comments_of_pull_request(pull_request)
745 767 comments = comments.filter(
746 768 coalesce(ChangesetComment.display_state, '') !=
747 769 ChangesetComment.COMMENT_OUTDATED)
748 770 return comments
749 771
750 772 def _all_inline_comments_of_pull_request(self, pull_request):
751 773 comments = Session().query(ChangesetComment)\
752 774 .filter(ChangesetComment.line_no != None)\
753 775 .filter(ChangesetComment.f_path != None)\
754 776 .filter(ChangesetComment.pull_request == pull_request)
755 777 return comments
756 778
757 779 def _all_general_comments_of_pull_request(self, pull_request):
758 780 comments = Session().query(ChangesetComment)\
759 781 .filter(ChangesetComment.line_no == None)\
760 782 .filter(ChangesetComment.f_path == None)\
761 783 .filter(ChangesetComment.pull_request == pull_request)
762 784
763 785 return comments
764 786
765 787 @staticmethod
766 788 def use_outdated_comments(pull_request):
767 789 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
768 790 settings = settings_model.get_general_settings()
769 791 return settings.get('rhodecode_use_outdated_comments', False)
770 792
771 793 def trigger_commit_comment_hook(self, repo, user, action, data=None):
772 794 repo = self._get_repo(repo)
773 795 target_scm = repo.scm_instance()
774 796 if action == 'create':
775 797 trigger_hook = hooks_utils.trigger_comment_commit_hooks
776 798 elif action == 'edit':
777 799 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
778 800 else:
779 801 return
780 802
781 803 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
782 804 repo, action, trigger_hook)
783 805 trigger_hook(
784 806 username=user.username,
785 807 repo_name=repo.repo_name,
786 808 repo_type=target_scm.alias,
787 809 repo=repo,
788 810 data=data)
789 811
790 812
791 813 def _parse_comment_line_number(line_no):
792 814 """
793 815 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
794 816 """
795 817 old_line = None
796 818 new_line = None
797 819 if line_no.startswith('o'):
798 820 old_line = int(line_no[1:])
799 821 elif line_no.startswith('n'):
800 822 new_line = int(line_no[1:])
801 823 else:
802 824 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
803 825 return diffs.DiffLineNumber(old_line, new_line)
804 826
805 827
806 828 def _diff_to_comment_line_number(diff_line):
807 829 if diff_line.new is not None:
808 830 return u'n{}'.format(diff_line.new)
809 831 elif diff_line.old is not None:
810 832 return u'o{}'.format(diff_line.old)
811 833 return u''
812 834
813 835
814 836 def _diff_line_delta(a, b):
815 837 if None not in (a.new, b.new):
816 838 return abs(a.new - b.new)
817 839 elif None not in (a.old, b.old):
818 840 return abs(a.old - b.old)
819 841 else:
820 842 raise ValueError(
821 843 "Cannot compute delta between {} and {}".format(a, b))
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,74 +1,74 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2017-2020 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 os
22 22
23 23 import colander
24 24
25 25 from rhodecode.translation import _
26 26 from rhodecode.model.validation_schema import preparers
27 27 from rhodecode.model.validation_schema import types
28 28
29 29
30 30 @colander.deferred
31 31 def deferred_lifetime_validator(node, kw):
32 32 options = kw.get('lifetime_options', [])
33 33 return colander.All(
34 34 colander.Range(min=-1, max=60 * 24 * 30 * 12),
35 35 colander.OneOf([x for x in options]))
36 36
37 37
38 38 def unique_gist_validator(node, value):
39 39 from rhodecode.model.db import Gist
40 40 existing = Gist.get_by_access_id(value)
41 41 if existing:
42 42 msg = _(u'Gist with name {} already exists').format(value)
43 43 raise colander.Invalid(node, msg)
44 44
45 45
46 46 def filename_validator(node, value):
47 47 if value != os.path.basename(value):
48 48 msg = _(u'Filename {} cannot be inside a directory').format(value)
49 49 raise colander.Invalid(node, msg)
50 50
51 51
52 52 comment_types = ['note', 'todo']
53 53
54 54
55 55 class CommentSchema(colander.MappingSchema):
56 56 from rhodecode.model.db import ChangesetComment, ChangesetStatus
57 57
58 58 comment_body = colander.SchemaNode(colander.String())
59 59 comment_type = colander.SchemaNode(
60 60 colander.String(),
61 61 validator=colander.OneOf(ChangesetComment.COMMENT_TYPES),
62 62 missing=ChangesetComment.COMMENT_TYPE_NOTE)
63
63 is_draft = colander.SchemaNode(colander.Boolean(),missing=False)
64 64 comment_file = colander.SchemaNode(colander.String(), missing=None)
65 65 comment_line = colander.SchemaNode(colander.String(), missing=None)
66 66 status_change = colander.SchemaNode(
67 67 colander.String(), missing=None,
68 68 validator=colander.OneOf([x[0] for x in ChangesetStatus.STATUSES]))
69 69 renderer_type = colander.SchemaNode(colander.String())
70 70
71 71 resolves_comment_id = colander.SchemaNode(colander.Integer(), missing=None)
72 72
73 73 user = colander.SchemaNode(types.StrOrIntType())
74 74 repo = colander.SchemaNode(types.StrOrIntType())
@@ -1,540 +1,600 b''
1 1
2 2
3 3 //BUTTONS
4 4 button,
5 5 .btn,
6 6 input[type="button"] {
7 7 -webkit-appearance: none;
8 8 display: inline-block;
9 9 margin: 0 @padding/3 0 0;
10 10 padding: @button-padding;
11 11 text-align: center;
12 12 font-size: @basefontsize;
13 13 line-height: 1em;
14 14 font-family: @text-light;
15 15 text-decoration: none;
16 16 text-shadow: none;
17 17 color: @grey2;
18 18 background-color: white;
19 19 background-image: none;
20 20 border: none;
21 21 .border ( @border-thickness-buttons, @grey5 );
22 22 .border-radius (@border-radius);
23 23 cursor: pointer;
24 24 white-space: nowrap;
25 25 -webkit-transition: background .3s,color .3s;
26 26 -moz-transition: background .3s,color .3s;
27 27 -o-transition: background .3s,color .3s;
28 28 transition: background .3s,color .3s;
29 29 box-shadow: @button-shadow;
30 30 -webkit-box-shadow: @button-shadow;
31 31
32 32
33 33
34 34 a {
35 35 display: block;
36 36 margin: 0;
37 37 padding: 0;
38 38 color: inherit;
39 39 text-decoration: none;
40 40
41 41 &:hover {
42 42 text-decoration: none;
43 43 }
44 44 }
45 45
46 46 &:focus,
47 47 &:active {
48 48 outline:none;
49 49 }
50 50
51 51 &:hover {
52 52 color: @rcdarkblue;
53 53 background-color: @grey6;
54 54
55 55 }
56 56
57 57 &.btn-active {
58 58 color: @rcdarkblue;
59 59 background-color: @grey6;
60 60 }
61 61
62 62 .icon-remove {
63 63 display: none;
64 64 }
65 65
66 66 //disabled buttons
67 67 //last; overrides any other styles
68 68 &:disabled {
69 69 opacity: .7;
70 70 cursor: auto;
71 71 background-color: white;
72 72 color: @grey4;
73 73 text-shadow: none;
74 74 }
75 75
76 76 &.no-margin {
77 77 margin: 0 0 0 0;
78 78 }
79 79
80 80
81 81
82 82 }
83 83
84 84
85 85 .btn-default {
86 86 border: @border-thickness solid @grey5;
87 87 background-image: none;
88 88 color: @grey2;
89 89
90 90 a {
91 91 color: @grey2;
92 92 }
93 93
94 94 &:hover,
95 95 &.active {
96 96 color: @rcdarkblue;
97 97 background-color: @white;
98 98 .border ( @border-thickness, @grey4 );
99 99
100 100 a {
101 101 color: @grey2;
102 102 }
103 103 }
104 104 &:disabled {
105 105 .border ( @border-thickness-buttons, @grey5 );
106 106 background-color: transparent;
107 107 }
108 108 &.btn-active {
109 109 color: @rcdarkblue;
110 110 background-color: @white;
111 111 .border ( @border-thickness, @rcdarkblue );
112 112 }
113 113 }
114 114
115 115 .btn-primary,
116 116 .btn-small, /* TODO: anderson: remove .btn-small to not mix with the new btn-sm */
117 117 .btn-success {
118 118 .border ( @border-thickness, @rcblue );
119 119 background-color: @rcblue;
120 120 color: white;
121 121
122 122 a {
123 123 color: white;
124 124 }
125 125
126 126 &:hover,
127 127 &.active {
128 128 .border ( @border-thickness, @rcdarkblue );
129 129 color: white;
130 130 background-color: @rcdarkblue;
131 131
132 132 a {
133 133 color: white;
134 134 }
135 135 }
136 136 &:disabled {
137 137 background-color: @rcblue;
138 138 }
139 139 }
140 140
141 141 .btn-secondary {
142 142 &:extend(.btn-default);
143 143
144 144 background-color: white;
145 145
146 146 &:focus {
147 147 outline: 0;
148 148 }
149 149
150 150 &:hover {
151 151 &:extend(.btn-default:hover);
152 152 }
153 153
154 154 &.btn-link {
155 155 &:extend(.btn-link);
156 156 color: @rcblue;
157 157 }
158 158
159 159 &:disabled {
160 160 color: @rcblue;
161 161 background-color: white;
162 162 }
163 163 }
164 164
165 .btn-warning,
166 165 .btn-danger,
167 166 .revoke_perm,
168 167 .btn-x,
169 168 .form .action_button.btn-x {
170 169 .border ( @border-thickness, @alert2 );
171 170 background-color: white;
172 171 color: @alert2;
173 172
174 173 a {
175 174 color: @alert2;
176 175 }
177 176
178 177 &:hover,
179 178 &.active {
180 179 .border ( @border-thickness, @alert2 );
181 180 color: white;
182 181 background-color: @alert2;
183 182
184 183 a {
185 184 color: white;
186 185 }
187 186 }
188 187
189 188 i {
190 189 display:none;
191 190 }
192 191
193 192 &:disabled {
194 193 background-color: white;
195 194 color: @alert2;
196 195 }
197 196 }
198 197
198 .btn-warning {
199 .border ( @border-thickness, @alert3 );
200 background-color: white;
201 color: @alert3;
202
203 a {
204 color: @alert3;
205 }
206
207 &:hover,
208 &.active {
209 .border ( @border-thickness, @alert3 );
210 color: white;
211 background-color: @alert3;
212
213 a {
214 color: white;
215 }
216 }
217
218 i {
219 display:none;
220 }
221
222 &:disabled {
223 background-color: white;
224 color: @alert3;
225 }
226 }
227
199 228 .btn-approved-status {
200 229 .border ( @border-thickness, @alert1 );
201 230 background-color: white;
202 231 color: @alert1;
203 232
204 233 }
205 234
206 235 .btn-rejected-status {
207 236 .border ( @border-thickness, @alert2 );
208 237 background-color: white;
209 238 color: @alert2;
210 239 }
211 240
212 241 .btn-sm,
213 242 .btn-mini,
214 243 .field-sm .btn {
215 244 padding: @padding/3;
216 245 }
217 246
218 247 .btn-xs {
219 248 padding: @padding/4;
220 249 }
221 250
222 251 .btn-lg {
223 252 padding: @padding * 1.2;
224 253 }
225 254
226 255 .btn-group {
227 256 display: inline-block;
228 257 .btn {
229 258 float: left;
230 259 margin: 0 0 0 0;
231 260 // first item
232 261 &:first-of-type:not(:last-of-type) {
233 262 border-radius: @border-radius 0 0 @border-radius;
234 263
235 264 }
236 265 // middle elements
237 266 &:not(:first-of-type):not(:last-of-type) {
238 267 border-radius: 0;
239 268 border-left-width: 0;
240 269 border-right-width: 0;
241 270 }
242 271 // last item
243 272 &:last-of-type:not(:first-of-type) {
244 273 border-radius: 0 @border-radius @border-radius 0;
245 274 }
246 275
247 276 &:only-child {
248 277 border-radius: @border-radius;
249 278 }
250 279 }
251 280
252 281 }
253 282
254 283
255 284 .btn-group-actions {
256 285 position: relative;
257 286 z-index: 50;
258 287
259 288 &:not(.open) .btn-action-switcher-container {
260 289 display: none;
261 290 }
262 291
263 292 .btn-more-option {
264 293 margin-left: -1px;
265 294 padding-left: 2px;
266 295 padding-right: 2px;
267 296 }
268 297 }
269 298
270 299
271 300 .btn-action-switcher-container {
272 301 position: absolute;
273 302 top: 100%;
274 303
275 304 &.left-align {
276 305 left: 0;
277 306 }
278 307 &.right-align {
279 308 right: 0;
280 309 }
281 310
282 311 }
283 312
284 313 .btn-action-switcher {
285 314 display: block;
286 315 position: relative;
287 316 z-index: 300;
288 317 max-width: 600px;
289 318 margin-top: 4px;
290 319 margin-bottom: 24px;
291 320 font-size: 14px;
292 321 font-weight: 400;
293 322 padding: 8px 0;
294 323 background-color: #fff;
295 324 border: 1px solid @grey4;
296 325 border-radius: 3px;
297 326 box-shadow: @dropdown-shadow;
298 327 overflow: auto;
299 328
300 329 li {
301 330 display: block;
302 331 text-align: left;
303 332 list-style: none;
304 333 padding: 5px 10px;
305 334 }
306 335
307 336 li .action-help-block {
308 337 font-size: 10px;
309 338 line-height: normal;
310 339 color: @grey4;
311 340 }
312 341
313 342 }
314 343
315 344 .btn-link {
316 345 background: transparent;
317 346 border: none;
318 347 padding: 0;
319 348 color: @rcblue;
320 349
321 350 &:hover {
322 351 background: transparent;
323 352 border: none;
324 353 color: @rcdarkblue;
325 354 }
326 355
327 356 //disabled buttons
328 357 //last; overrides any other styles
329 358 &:disabled {
330 359 opacity: .7;
331 360 cursor: auto;
332 361 background-color: white;
333 362 color: @grey4;
334 363 text-shadow: none;
335 364 }
336 365
337 366 // TODO: johbo: Check if we can avoid this, indicates that the structure
338 367 // is not yet good.
339 368 // lisa: The button CSS reflects the button HTML; both need a cleanup.
340 369 &.btn-danger {
341 370 color: @alert2;
342 371
343 372 &:hover {
344 373 color: darken(@alert2,30%);
345 374 }
346 375
347 376 &:disabled {
348 377 color: @alert2;
349 378 }
350 379 }
351 380 }
352 381
353 382 .btn-social {
354 383 &:extend(.btn-default);
355 384 margin: 5px 5px 5px 0px;
356 385 min-width: 160px;
357 386 }
358 387
359 388 // TODO: johbo: check these exceptions
360 389
361 390 .links {
362 391
363 392 .btn + .btn {
364 393 margin-top: @padding;
365 394 }
366 395 }
367 396
368 397
369 398 .action_button {
370 399 display:inline;
371 400 margin: 0;
372 401 padding: 0 1em 0 0;
373 402 font-size: inherit;
374 403 color: @rcblue;
375 404 border: none;
376 405 border-radius: 0;
377 406 background-color: transparent;
378 407
379 408 &.last-item {
380 409 border: none;
381 410 padding: 0 0 0 0;
382 411 }
383 412
384 413 &:last-child {
385 414 border: none;
386 415 padding: 0 0 0 0;
387 416 }
388 417
389 418 &:hover {
390 419 color: @rcdarkblue;
391 420 background-color: transparent;
392 421 border: none;
393 422 }
394 423 .noselect
395 424 }
396 425
397 426 .grid_delete {
398 427 .action_button {
399 428 border: none;
400 429 }
401 430 }
402 431
403 432
433 input[type="submit"].btn-warning {
434 &:extend(.btn-warning);
435
436 &:focus {
437 outline: 0;
438 }
439
440 &:hover {
441 &:extend(.btn-warning:hover);
442 }
443
444 &.btn-link {
445 &:extend(.btn-link);
446 color: @alert3;
447
448 &:disabled {
449 color: @alert3;
450 background-color: transparent;
451 }
452 }
453
454 &:disabled {
455 .border ( @border-thickness-buttons, @alert3 );
456 background-color: white;
457 color: @alert3;
458 opacity: 0.5;
459 }
460 }
461
462
463
404 464 // TODO: johbo: Form button tweaks, check if we can use the classes instead
405 465 input[type="submit"] {
406 466 &:extend(.btn-primary);
407 467
408 468 &:focus {
409 469 outline: 0;
410 470 }
411 471
412 472 &:hover {
413 473 &:extend(.btn-primary:hover);
414 474 }
415 475
416 476 &.btn-link {
417 477 &:extend(.btn-link);
418 478 color: @rcblue;
419 479
420 480 &:disabled {
421 481 color: @rcblue;
422 482 background-color: transparent;
423 483 }
424 484 }
425 485
426 486 &:disabled {
427 487 .border ( @border-thickness-buttons, @rcblue );
428 488 background-color: @rcblue;
429 489 color: white;
430 490 opacity: 0.5;
431 491 }
432 492 }
433 493
434 494 input[type="reset"] {
435 495 &:extend(.btn-default);
436 496
437 497 // TODO: johbo: Check if this tweak can be avoided.
438 498 background: transparent;
439 499
440 500 &:focus {
441 501 outline: 0;
442 502 }
443 503
444 504 &:hover {
445 505 &:extend(.btn-default:hover);
446 506 }
447 507
448 508 &.btn-link {
449 509 &:extend(.btn-link);
450 510 color: @rcblue;
451 511
452 512 &:disabled {
453 513 border: none;
454 514 }
455 515 }
456 516
457 517 &:disabled {
458 518 .border ( @border-thickness-buttons, @rcblue );
459 519 background-color: white;
460 520 color: @rcblue;
461 521 }
462 522 }
463 523
464 524 input[type="submit"],
465 525 input[type="reset"] {
466 526 &.btn-danger {
467 527 &:extend(.btn-danger);
468 528
469 529 &:focus {
470 530 outline: 0;
471 531 }
472 532
473 533 &:hover {
474 534 &:extend(.btn-danger:hover);
475 535 }
476 536
477 537 &.btn-link {
478 538 &:extend(.btn-link);
479 539 color: @alert2;
480 540
481 541 &:hover {
482 542 color: darken(@alert2,30%);
483 543 }
484 544 }
485 545
486 546 &:disabled {
487 547 color: @alert2;
488 548 background-color: white;
489 549 }
490 550 }
491 551 &.btn-danger-action {
492 552 .border ( @border-thickness, @alert2 );
493 553 background-color: @alert2;
494 554 color: white;
495 555
496 556 a {
497 557 color: white;
498 558 }
499 559
500 560 &:hover {
501 561 background-color: darken(@alert2,20%);
502 562 }
503 563
504 564 &.active {
505 565 .border ( @border-thickness, @alert2 );
506 566 color: white;
507 567 background-color: @alert2;
508 568
509 569 a {
510 570 color: white;
511 571 }
512 572 }
513 573
514 574 &:disabled {
515 575 background-color: white;
516 576 color: @alert2;
517 577 }
518 578 }
519 579 }
520 580
521 581
522 582 .button-links {
523 583 float: left;
524 584 display: inline;
525 585 margin: 0;
526 586 padding-left: 0;
527 587 list-style: none;
528 588 text-align: right;
529 589
530 590 li {
531 591
532 592
533 593 }
534 594
535 595 li.active {
536 596 background-color: @grey6;
537 597 .border ( @border-thickness, @grey4 );
538 598 }
539 599
540 600 }
@@ -1,635 +1,642 b''
1 1 // comments.less
2 2 // For use in RhodeCode applications;
3 3 // see style guide documentation for guidelines.
4 4
5 5
6 6 // Comments
7 7 @comment-outdated-opacity: 0.6;
8 8
9 9 .comments {
10 10 width: 100%;
11 11 }
12 12
13 13 .comments-heading {
14 14 margin-bottom: -1px;
15 15 background: @grey6;
16 16 display: block;
17 17 padding: 10px 0px;
18 18 font-size: 18px
19 19 }
20 20
21 21 #comment-tr-show {
22 22 padding: 5px 0;
23 23 }
24 24
25 25 tr.inline-comments div {
26 26 max-width: 100%;
27 27
28 28 p {
29 29 white-space: normal;
30 30 }
31 31
32 32 code, pre, .code, dd {
33 33 overflow-x: auto;
34 34 width: 1062px;
35 35 }
36 36
37 37 dd {
38 38 width: auto;
39 39 }
40 40 }
41 41
42 42 #injected_page_comments {
43 43 .comment-previous-link,
44 44 .comment-next-link,
45 45 .comment-links-divider {
46 46 display: none;
47 47 }
48 48 }
49 49
50 50 .add-comment {
51 51 margin-bottom: 10px;
52 52 }
53 53 .hide-comment-button .add-comment {
54 54 display: none;
55 55 }
56 56
57 57 .comment-bubble {
58 58 color: @grey4;
59 59 margin-top: 4px;
60 60 margin-right: 30px;
61 61 visibility: hidden;
62 62 }
63 63
64 .comment-draft {
65 float: left;
66 margin-right: 10px;
67 font-weight: 600;
68 color: @alert3;
69 }
70
64 71 .comment-label {
65 72 float: left;
66 73
67 74 padding: 0.4em 0.4em;
68 75 margin: 2px 4px 0px 0px;
69 76 display: inline-block;
70 77 min-height: 0;
71 78
72 79 text-align: center;
73 80 font-size: 10px;
74 81 line-height: .8em;
75 82
76 83 font-family: @text-italic;
77 84 font-style: italic;
78 85 background: #fff none;
79 86 color: @grey3;
80 87 border: 1px solid @grey4;
81 88 white-space: nowrap;
82 89
83 90 text-transform: uppercase;
84 91 min-width: 50px;
85 92 border-radius: 4px;
86 93
87 94 &.todo {
88 95 color: @color5;
89 96 font-style: italic;
90 97 font-weight: @text-bold-italic-weight;
91 98 font-family: @text-bold-italic;
92 99 }
93 100
94 101 .resolve {
95 102 cursor: pointer;
96 103 text-decoration: underline;
97 104 }
98 105
99 106 .resolved {
100 107 text-decoration: line-through;
101 108 color: @color1;
102 109 }
103 110 .resolved a {
104 111 text-decoration: line-through;
105 112 color: @color1;
106 113 }
107 114 .resolve-text {
108 115 color: @color1;
109 116 margin: 2px 8px;
110 117 font-family: @text-italic;
111 118 font-style: italic;
112 119 }
113 120 }
114 121
115 122 .has-spacer-after {
116 123 &:after {
117 124 content: ' | ';
118 125 color: @grey5;
119 126 }
120 127 }
121 128
122 129 .has-spacer-before {
123 130 &:before {
124 131 content: ' | ';
125 132 color: @grey5;
126 133 }
127 134 }
128 135
129 136 .comment {
130 137
131 138 &.comment-general {
132 139 border: 1px solid @grey5;
133 140 padding: 5px 5px 5px 5px;
134 141 }
135 142
136 143 margin: @padding 0;
137 144 padding: 4px 0 0 0;
138 145 line-height: 1em;
139 146
140 147 .rc-user {
141 148 min-width: 0;
142 149 margin: 0px .5em 0 0;
143 150
144 151 .user {
145 152 display: inline;
146 153 }
147 154 }
148 155
149 156 .meta {
150 157 position: relative;
151 158 width: 100%;
152 159 border-bottom: 1px solid @grey5;
153 160 margin: -5px 0px;
154 161 line-height: 24px;
155 162
156 163 &:hover .permalink {
157 164 visibility: visible;
158 165 color: @rcblue;
159 166 }
160 167 }
161 168
162 169 .author,
163 170 .date {
164 171 display: inline;
165 172
166 173 &:after {
167 174 content: ' | ';
168 175 color: @grey5;
169 176 }
170 177 }
171 178
172 179 .author-general img {
173 180 top: 3px;
174 181 }
175 182 .author-inline img {
176 183 top: 3px;
177 184 }
178 185
179 186 .status-change,
180 187 .permalink,
181 188 .changeset-status-lbl {
182 189 display: inline;
183 190 }
184 191
185 192 .permalink {
186 193 visibility: hidden;
187 194 }
188 195
189 196 .comment-links-divider {
190 197 display: inline;
191 198 }
192 199
193 200 .comment-links-block {
194 201 float:right;
195 202 text-align: right;
196 203 min-width: 85px;
197 204
198 205 [class^="icon-"]:before,
199 206 [class*=" icon-"]:before {
200 207 margin-left: 0;
201 208 margin-right: 0;
202 209 }
203 210 }
204 211
205 212 .comment-previous-link {
206 213 display: inline-block;
207 214
208 215 .arrow_comment_link{
209 216 cursor: pointer;
210 217 i {
211 218 font-size:10px;
212 219 }
213 220 }
214 221 .arrow_comment_link.disabled {
215 222 cursor: default;
216 223 color: @grey5;
217 224 }
218 225 }
219 226
220 227 .comment-next-link {
221 228 display: inline-block;
222 229
223 230 .arrow_comment_link{
224 231 cursor: pointer;
225 232 i {
226 233 font-size:10px;
227 234 }
228 235 }
229 236 .arrow_comment_link.disabled {
230 237 cursor: default;
231 238 color: @grey5;
232 239 }
233 240 }
234 241
235 242 .delete-comment {
236 243 display: inline-block;
237 244 color: @rcblue;
238 245
239 246 &:hover {
240 247 cursor: pointer;
241 248 }
242 249 }
243 250
244 251 .text {
245 252 clear: both;
246 253 .border-radius(@border-radius);
247 254 .box-sizing(border-box);
248 255
249 256 .markdown-block p,
250 257 .rst-block p {
251 258 margin: .5em 0 !important;
252 259 // TODO: lisa: This is needed because of other rst !important rules :[
253 260 }
254 261 }
255 262
256 263 .pr-version {
257 264 display: inline-block;
258 265 }
259 266 .pr-version-inline {
260 267 display: inline-block;
261 268 }
262 269 .pr-version-num {
263 270 font-size: 10px;
264 271 }
265 272 }
266 273
267 274 @comment-padding: 5px;
268 275
269 276 .general-comments {
270 277 .comment-outdated {
271 278 opacity: @comment-outdated-opacity;
272 279 }
273 280 }
274 281
275 282 .inline-comments {
276 283 border-radius: @border-radius;
277 284 .comment {
278 285 margin: 0;
279 286 border-radius: @border-radius;
280 287 }
281 288 .comment-outdated {
282 289 opacity: @comment-outdated-opacity;
283 290 }
284 291
285 292 .comment-inline {
286 293 background: white;
287 294 padding: @comment-padding @comment-padding;
288 295 border: @comment-padding solid @grey6;
289 296
290 297 .text {
291 298 border: none;
292 299 }
293 300 .meta {
294 301 border-bottom: 1px solid @grey6;
295 302 margin: -5px 0px;
296 303 line-height: 24px;
297 304 }
298 305 }
299 306 .comment-selected {
300 307 border-left: 6px solid @comment-highlight-color;
301 308 }
302 309 .comment-inline-form {
303 310 padding: @comment-padding;
304 311 display: none;
305 312 }
306 313 .cb-comment-add-button {
307 314 margin: @comment-padding;
308 315 }
309 316 /* hide add comment button when form is open */
310 317 .comment-inline-form-open ~ .cb-comment-add-button {
311 318 display: none;
312 319 }
313 320 .comment-inline-form-open {
314 321 display: block;
315 322 }
316 323 /* hide add comment button when form but no comments */
317 324 .comment-inline-form:first-child + .cb-comment-add-button {
318 325 display: none;
319 326 }
320 327 /* hide add comment button when no comments or form */
321 328 .cb-comment-add-button:first-child {
322 329 display: none;
323 330 }
324 331 /* hide add comment button when only comment is being deleted */
325 332 .comment-deleting:first-child + .cb-comment-add-button {
326 333 display: none;
327 334 }
328 335 }
329 336
330 337
331 338 .show-outdated-comments {
332 339 display: inline;
333 340 color: @rcblue;
334 341 }
335 342
336 343 // Comment Form
337 344 div.comment-form {
338 345 margin-top: 20px;
339 346 }
340 347
341 348 .comment-form strong {
342 349 display: block;
343 350 margin-bottom: 15px;
344 351 }
345 352
346 353 .comment-form textarea {
347 354 width: 100%;
348 355 height: 100px;
349 356 font-family: @text-monospace;
350 357 }
351 358
352 359 form.comment-form {
353 360 margin-top: 10px;
354 361 margin-left: 10px;
355 362 }
356 363
357 364 .comment-inline-form .comment-block-ta,
358 365 .comment-form .comment-block-ta,
359 366 .comment-form .preview-box {
360 367 .border-radius(@border-radius);
361 368 .box-sizing(border-box);
362 369 background-color: white;
363 370 }
364 371
365 372 .comment-form-submit {
366 373 margin-top: 5px;
367 374 margin-left: 525px;
368 375 }
369 376
370 377 .file-comments {
371 378 display: none;
372 379 }
373 380
374 381 .comment-form .preview-box.unloaded,
375 382 .comment-inline-form .preview-box.unloaded {
376 383 height: 50px;
377 384 text-align: center;
378 385 padding: 20px;
379 386 background-color: white;
380 387 }
381 388
382 389 .comment-footer {
383 390 position: relative;
384 391 width: 100%;
385 392 min-height: 42px;
386 393
387 394 .status_box,
388 395 .cancel-button {
389 396 float: left;
390 397 display: inline-block;
391 398 }
392 399
393 400 .status_box {
394 401 margin-left: 10px;
395 402 }
396 403
397 404 .action-buttons {
398 405 float: left;
399 406 display: inline-block;
400 407 }
401 408
402 409 .action-buttons-extra {
403 410 display: inline-block;
404 411 }
405 412 }
406 413
407 414 .comment-form {
408 415
409 416 .comment {
410 417 margin-left: 10px;
411 418 }
412 419
413 420 .comment-help {
414 421 color: @grey4;
415 422 padding: 5px 0 5px 0;
416 423 }
417 424
418 425 .comment-title {
419 426 padding: 5px 0 5px 0;
420 427 }
421 428
422 429 .comment-button {
423 430 display: inline-block;
424 431 }
425 432
426 433 .comment-button-input {
427 434 margin-right: 0;
428 435 }
429 436
430 437 .comment-footer {
431 438 margin-bottom: 50px;
432 439 margin-top: 10px;
433 440 }
434 441 }
435 442
436 443
437 444 .comment-form-login {
438 445 .comment-help {
439 446 padding: 0.7em; //same as the button
440 447 }
441 448
442 449 div.clearfix {
443 450 clear: both;
444 451 width: 100%;
445 452 display: block;
446 453 }
447 454 }
448 455
449 456 .comment-version-select {
450 457 margin: 0px;
451 458 border-radius: inherit;
452 459 border-color: @grey6;
453 460 height: 20px;
454 461 }
455 462
456 463 .comment-type {
457 464 margin: 0px;
458 465 border-radius: inherit;
459 466 border-color: @grey6;
460 467 }
461 468
462 469 .preview-box {
463 470 min-height: 105px;
464 471 margin-bottom: 15px;
465 472 background-color: white;
466 473 .border-radius(@border-radius);
467 474 .box-sizing(border-box);
468 475 }
469 476
470 477 .add-another-button {
471 478 margin-left: 10px;
472 479 margin-top: 10px;
473 480 margin-bottom: 10px;
474 481 }
475 482
476 483 .comment .buttons {
477 484 float: right;
478 485 margin: -1px 0px 0px 0px;
479 486 }
480 487
481 488 // Inline Comment Form
482 489 .injected_diff .comment-inline-form,
483 490 .comment-inline-form {
484 491 background-color: white;
485 492 margin-top: 10px;
486 493 margin-bottom: 20px;
487 494 }
488 495
489 496 .inline-form {
490 497 padding: 10px 7px;
491 498 }
492 499
493 500 .inline-form div {
494 501 max-width: 100%;
495 502 }
496 503
497 504 .overlay {
498 505 display: none;
499 506 position: absolute;
500 507 width: 100%;
501 508 text-align: center;
502 509 vertical-align: middle;
503 510 font-size: 16px;
504 511 background: none repeat scroll 0 0 white;
505 512
506 513 &.submitting {
507 514 display: block;
508 515 opacity: 0.5;
509 516 z-index: 100;
510 517 }
511 518 }
512 519 .comment-inline-form .overlay.submitting .overlay-text {
513 520 margin-top: 5%;
514 521 }
515 522
516 523 .comment-inline-form .clearfix,
517 524 .comment-form .clearfix {
518 525 .border-radius(@border-radius);
519 526 margin: 0px;
520 527 }
521 528
522 529 .comment-inline-form .comment-footer {
523 530 margin: 10px 0px 0px 0px;
524 531 }
525 532
526 533 .hide-inline-form-button {
527 534 margin-left: 5px;
528 535 }
529 536 .comment-button .hide-inline-form {
530 537 background: white;
531 538 }
532 539
533 540 .comment-area {
534 541 padding: 6px 8px;
535 542 border: 1px solid @grey5;
536 543 .border-radius(@border-radius);
537 544
538 545 .resolve-action {
539 546 padding: 1px 0px 0px 6px;
540 547 }
541 548
542 549 }
543 550
544 551 comment-area-text {
545 552 color: @grey3;
546 553 }
547 554
548 555 .comment-area-header {
549 556 height: 35px;
550 557 }
551 558
552 559 .comment-area-header .nav-links {
553 560 display: flex;
554 561 flex-flow: row wrap;
555 562 -webkit-flex-flow: row wrap;
556 563 width: 100%;
557 564 }
558 565
559 566 .comment-area-footer {
560 567 min-height: 30px;
561 568 }
562 569
563 570 .comment-footer .toolbar {
564 571
565 572 }
566 573
567 574 .comment-attachment-uploader {
568 575 border: 1px dashed white;
569 576 border-radius: @border-radius;
570 577 margin-top: -10px;
571 578 line-height: 30px;
572 579 &.dz-drag-hover {
573 580 border-color: @grey3;
574 581 }
575 582
576 583 .dz-error-message {
577 584 padding-top: 0;
578 585 }
579 586 }
580 587
581 588 .comment-attachment-text {
582 589 clear: both;
583 590 font-size: 11px;
584 591 color: #8F8F8F;
585 592 width: 100%;
586 593 .pick-attachment {
587 594 color: #8F8F8F;
588 595 }
589 596 .pick-attachment:hover {
590 597 color: @rcblue;
591 598 }
592 599 }
593 600
594 601 .nav-links {
595 602 padding: 0;
596 603 margin: 0;
597 604 list-style: none;
598 605 height: auto;
599 606 border-bottom: 1px solid @grey5;
600 607 }
601 608 .nav-links li {
602 609 display: inline-block;
603 610 list-style-type: none;
604 611 }
605 612
606 613 .nav-links li a.disabled {
607 614 cursor: not-allowed;
608 615 }
609 616
610 617 .nav-links li.active a {
611 618 border-bottom: 2px solid @rcblue;
612 619 color: #000;
613 620 font-weight: 600;
614 621 }
615 622 .nav-links li a {
616 623 display: inline-block;
617 624 padding: 0px 10px 5px 10px;
618 625 margin-bottom: -1px;
619 626 font-size: 14px;
620 627 line-height: 28px;
621 628 color: #8f8f8f;
622 629 border-bottom: 2px solid transparent;
623 630 }
624 631
625 632 .toolbar-text {
626 633 float: right;
627 634 font-size: 11px;
628 635 color: @grey4;
629 636 text-align: right;
630 637
631 638 a {
632 639 color: @grey4;
633 640 }
634 641 }
635 642
@@ -1,843 +1,850 b''
1 1 // # Copyright (C) 2010-2020 RhodeCode GmbH
2 2 // #
3 3 // # This program is free software: you can redistribute it and/or modify
4 4 // # it under the terms of the GNU Affero General Public License, version 3
5 5 // # (only), as published by the Free Software Foundation.
6 6 // #
7 7 // # This program is distributed in the hope that it will be useful,
8 8 // # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 // # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 // # GNU General Public License for more details.
11 11 // #
12 12 // # You should have received a copy of the GNU Affero General Public License
13 13 // # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 // #
15 15 // # This program is dual-licensed. If you wish to learn more about the
16 16 // # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 // # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 /**
20 20 * Code Mirror
21 21 */
22 22 // global code-mirror logger;, to enable run
23 23 // Logger.get('CodeMirror').setLevel(Logger.DEBUG)
24 24
25 25 cmLog = Logger.get('CodeMirror');
26 26 cmLog.setLevel(Logger.OFF);
27 27
28 28
29 29 //global cache for inline forms
30 30 var userHintsCache = {};
31 31
32 32 // global timer, used to cancel async loading
33 33 var CodeMirrorLoadUserHintTimer;
34 34
35 35 var escapeRegExChars = function(value) {
36 36 return value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
37 37 };
38 38
39 39 /**
40 40 * Load hints from external source returns an array of objects in a format
41 41 * that hinting lib requires
42 42 * @returns {Array}
43 43 */
44 44 var CodeMirrorLoadUserHints = function(query, triggerHints) {
45 45 cmLog.debug('Loading mentions users via AJAX');
46 46 var _users = [];
47 47 $.ajax({
48 48 type: 'GET',
49 49 data: {query: query},
50 50 url: pyroutes.url('user_autocomplete_data'),
51 51 headers: {'X-PARTIAL-XHR': true},
52 52 async: true
53 53 })
54 54 .done(function(data) {
55 55 var tmpl = '<img class="gravatar" src="{0}"/>{1}';
56 56 $.each(data.suggestions, function(i) {
57 57 var userObj = data.suggestions[i];
58 58
59 59 if (userObj.username !== "default") {
60 60 _users.push({
61 61 text: userObj.username + " ",
62 62 org_text: userObj.username,
63 63 displayText: userObj.value_display, // search that field
64 64 // internal caches
65 65 _icon_link: userObj.icon_link,
66 66 _text: userObj.value_display,
67 67
68 68 render: function(elt, data, completion) {
69 69 var el = document.createElement('div');
70 70 el.className = "CodeMirror-hint-entry";
71 71 el.innerHTML = tmpl.format(
72 72 completion._icon_link, completion._text);
73 73 elt.appendChild(el);
74 74 }
75 75 });
76 76 }
77 77 });
78 78 cmLog.debug('Mention users loaded');
79 79 // set to global cache
80 80 userHintsCache[query] = _users;
81 81 triggerHints(userHintsCache[query]);
82 82 })
83 83 .fail(function(data, textStatus, xhr) {
84 84 alert("error processing request. \n" +
85 85 "Error code {0} ({1}).".format(data.status, data.statusText));
86 86 });
87 87 };
88 88
89 89 /**
90 90 * filters the results based on the current context
91 91 * @param users
92 92 * @param context
93 93 * @returns {Array}
94 94 */
95 95 var CodeMirrorFilterUsers = function(users, context) {
96 96 var MAX_LIMIT = 10;
97 97 var filtered_users = [];
98 98 var curWord = context.string;
99 99
100 100 cmLog.debug('Filtering users based on query:', curWord);
101 101 $.each(users, function(i) {
102 102 var match = users[i];
103 103 var searchText = match.displayText;
104 104
105 105 if (!curWord ||
106 106 searchText.toLowerCase().lastIndexOf(curWord) !== -1) {
107 107 // reset state
108 108 match._text = match.displayText;
109 109 if (curWord) {
110 110 // do highlighting
111 111 var pattern = '(' + escapeRegExChars(curWord) + ')';
112 112 match._text = searchText.replace(
113 113 new RegExp(pattern, 'gi'), '<strong>$1<\/strong>');
114 114 }
115 115
116 116 filtered_users.push(match);
117 117 }
118 118 // to not return to many results, use limit of filtered results
119 119 if (filtered_users.length > MAX_LIMIT) {
120 120 return false;
121 121 }
122 122 });
123 123
124 124 return filtered_users;
125 125 };
126 126
127 127 var CodeMirrorMentionHint = function(editor, callback, options) {
128 128 var cur = editor.getCursor();
129 129 var curLine = editor.getLine(cur.line).slice(0, cur.ch);
130 130
131 131 // match on @ +1char
132 132 var tokenMatch = new RegExp(
133 133 '(^@| @)([a-zA-Z0-9]{1}[a-zA-Z0-9\-\_\.]*)$').exec(curLine);
134 134
135 135 var tokenStr = '';
136 136 if (tokenMatch !== null && tokenMatch.length > 0){
137 137 tokenStr = tokenMatch[0].strip();
138 138 } else {
139 139 // skip if we didn't match our token
140 140 return;
141 141 }
142 142
143 143 var context = {
144 144 start: (cur.ch - tokenStr.length) + 1,
145 145 end: cur.ch,
146 146 string: tokenStr.slice(1),
147 147 type: null
148 148 };
149 149
150 150 // case when we put the @sign in fron of a string,
151 151 // eg <@ we put it here>sometext then we need to prepend to text
152 152 if (context.end > cur.ch) {
153 153 context.start = context.start + 1; // we add to the @ sign
154 154 context.end = cur.ch; // don't eat front part just append
155 155 context.string = context.string.slice(1, cur.ch - context.start);
156 156 }
157 157
158 158 cmLog.debug('Mention context', context);
159 159
160 160 var triggerHints = function(userHints){
161 161 return callback({
162 162 list: CodeMirrorFilterUsers(userHints, context),
163 163 from: CodeMirror.Pos(cur.line, context.start),
164 164 to: CodeMirror.Pos(cur.line, context.end)
165 165 });
166 166 };
167 167
168 168 var queryBasedHintsCache = undefined;
169 169 // if we have something in the cache, try to fetch the query based cache
170 170 if (userHintsCache !== {}){
171 171 queryBasedHintsCache = userHintsCache[context.string];
172 172 }
173 173
174 174 if (queryBasedHintsCache !== undefined) {
175 175 cmLog.debug('Users loaded from cache');
176 176 triggerHints(queryBasedHintsCache);
177 177 } else {
178 178 // this takes care for async loading, and then displaying results
179 179 // and also propagates the userHintsCache
180 180 window.clearTimeout(CodeMirrorLoadUserHintTimer);
181 181 CodeMirrorLoadUserHintTimer = setTimeout(function() {
182 182 CodeMirrorLoadUserHints(context.string, triggerHints);
183 183 }, 300);
184 184 }
185 185 };
186 186
187 187 var CodeMirrorCompleteAfter = function(cm, pred) {
188 188 var options = {
189 189 completeSingle: false,
190 190 async: true,
191 191 closeOnUnfocus: true
192 192 };
193 193 var cur = cm.getCursor();
194 194 setTimeout(function() {
195 195 if (!cm.state.completionActive) {
196 196 cmLog.debug('Trigger mentions hinting');
197 197 CodeMirror.showHint(cm, CodeMirror.hint.mentions, options);
198 198 }
199 199 }, 100);
200 200
201 201 // tell CodeMirror we didn't handle the key
202 202 // trick to trigger on a char but still complete it
203 203 return CodeMirror.Pass;
204 204 };
205 205
206 206 var initCodeMirror = function(textAreadId, resetUrl, focus, options) {
207 207 if (textAreadId.substr(0,1) === "#"){
208 208 var ta = $(textAreadId).get(0);
209 209 }else {
210 210 var ta = $('#' + textAreadId).get(0);
211 211 }
212 212
213 213 if (focus === undefined) {
214 214 focus = true;
215 215 }
216 216
217 217 // default options
218 218 var codeMirrorOptions = {
219 219 mode: "null",
220 220 lineNumbers: true,
221 221 indentUnit: 4,
222 222 autofocus: focus
223 223 };
224 224
225 225 if (options !== undefined) {
226 226 // extend with custom options
227 227 codeMirrorOptions = $.extend(true, codeMirrorOptions, options);
228 228 }
229 229
230 230 var myCodeMirror = CodeMirror.fromTextArea(ta, codeMirrorOptions);
231 231
232 232 $('#reset').on('click', function(e) {
233 233 window.location = resetUrl;
234 234 });
235 235
236 236 return myCodeMirror;
237 237 };
238 238
239 239
240 240 var initMarkupCodeMirror = function(textAreadId, focus, options) {
241 241 var initialHeight = 100;
242 242
243 243 var ta = $(textAreadId).get(0);
244 244 if (focus === undefined) {
245 245 focus = true;
246 246 }
247 247
248 248 // default options
249 249 var codeMirrorOptions = {
250 250 lineNumbers: false,
251 251 indentUnit: 4,
252 252 viewportMargin: 30,
253 253 // this is a trick to trigger some logic behind codemirror placeholder
254 254 // it influences styling and behaviour.
255 255 placeholder: " ",
256 256 lineWrapping: true,
257 257 autofocus: focus
258 258 };
259 259
260 260 if (options !== undefined) {
261 261 // extend with custom options
262 262 codeMirrorOptions = $.extend(true, codeMirrorOptions, options);
263 263 }
264 264
265 265 var cm = CodeMirror.fromTextArea(ta, codeMirrorOptions);
266 266 cm.setSize(null, initialHeight);
267 267 cm.setOption("mode", DEFAULT_RENDERER);
268 268 CodeMirror.autoLoadMode(cm, DEFAULT_RENDERER); // load rst or markdown mode
269 269 cmLog.debug('Loading codemirror mode', DEFAULT_RENDERER);
270 270
271 271 // start listening on changes to make auto-expanded editor
272 272 cm.on("change", function (instance, changeObj) {
273 273 var height = initialHeight;
274 274 var lines = instance.lineCount();
275 275 if (lines > 6 && lines < 20) {
276 276 height = "auto";
277 277 } else if (lines >= 20) {
278 278 height = 20 * 15;
279 279 }
280 280 instance.setSize(null, height);
281 281
282 282 // detect if the change was trigger by auto desc, or user input
283 283 var changeOrigin = changeObj.origin;
284 284
285 285 if (changeOrigin === "setValue") {
286 286 cmLog.debug('Change triggered by setValue');
287 287 }
288 288 else {
289 289 cmLog.debug('user triggered change !');
290 290 // set special marker to indicate user has created an input.
291 291 instance._userDefinedValue = true;
292 292 }
293 293
294 294 });
295 295
296 296 return cm;
297 297 };
298 298
299 299
300 300 var initCommentBoxCodeMirror = function(CommentForm, textAreaId, triggerActions){
301 301 var initialHeight = 100;
302 302
303 303 if (typeof userHintsCache === "undefined") {
304 304 userHintsCache = {};
305 305 cmLog.debug('Init empty cache for mentions');
306 306 }
307 307 if (!$(textAreaId).get(0)) {
308 308 cmLog.debug('Element for textarea not found', textAreaId);
309 309 return;
310 310 }
311 311 /**
312 312 * Filter action based on typed in text
313 313 * @param actions
314 314 * @param context
315 315 * @returns {Array}
316 316 */
317 317
318 318 var filterActions = function(actions, context){
319 319
320 320 var MAX_LIMIT = 10;
321 321 var filtered_actions = [];
322 322 var curWord = context.string;
323 323
324 324 cmLog.debug('Filtering actions based on query:', curWord);
325 325 $.each(actions, function(i) {
326 326 var match = actions[i];
327 327 var searchText = match.searchText;
328 328
329 329 if (!curWord ||
330 330 searchText.toLowerCase().lastIndexOf(curWord) !== -1) {
331 331 // reset state
332 332 match._text = match.displayText;
333 333 if (curWord) {
334 334 // do highlighting
335 335 var pattern = '(' + escapeRegExChars(curWord) + ')';
336 336 match._text = searchText.replace(
337 337 new RegExp(pattern, 'gi'), '<strong>$1<\/strong>');
338 338 }
339 339
340 340 filtered_actions.push(match);
341 341 }
342 342 // to not return to many results, use limit of filtered results
343 343 if (filtered_actions.length > MAX_LIMIT) {
344 344 return false;
345 345 }
346 346 });
347 347
348 348 return filtered_actions;
349 349 };
350 350
351 351 var submitForm = function(cm, pred) {
352 $(cm.display.input.textarea.form).submit();
352 $(cm.display.input.textarea.form).find('.submit-comment-action').click();
353 return CodeMirror.Pass;
354 };
355
356 var submitFormAsDraft = function(cm, pred) {
357 $(cm.display.input.textarea.form).find('.submit-draft-action').click();
353 358 return CodeMirror.Pass;
354 359 };
355 360
356 361 var completeActions = function(actions){
357 362
358 363 var registeredActions = [];
359 364 var allActions = [
360 365 {
361 366 text: "approve",
362 367 searchText: "status approved",
363 368 displayText: _gettext('Set status to Approved'),
364 369 hint: function(CodeMirror, data, completion) {
365 370 CodeMirror.replaceRange("", completion.from || data.from,
366 371 completion.to || data.to, "complete");
367 372 $(CommentForm.statusChange).select2("val", 'approved').trigger('change');
368 373 },
369 374 render: function(elt, data, completion) {
370 375 var el = document.createElement('i');
371 376
372 377 el.className = "icon-circle review-status-approved";
373 378 elt.appendChild(el);
374 379
375 380 el = document.createElement('span');
376 381 el.innerHTML = completion.displayText;
377 382 elt.appendChild(el);
378 383 }
379 384 },
380 385 {
381 386 text: "reject",
382 387 searchText: "status rejected",
383 388 displayText: _gettext('Set status to Rejected'),
384 389 hint: function(CodeMirror, data, completion) {
385 390 CodeMirror.replaceRange("", completion.from || data.from,
386 391 completion.to || data.to, "complete");
387 392 $(CommentForm.statusChange).select2("val", 'rejected').trigger('change');
388 393 },
389 394 render: function(elt, data, completion) {
390 395 var el = document.createElement('i');
391 396 el.className = "icon-circle review-status-rejected";
392 397 elt.appendChild(el);
393 398
394 399 el = document.createElement('span');
395 400 el.innerHTML = completion.displayText;
396 401 elt.appendChild(el);
397 402 }
398 403 },
399 404 {
400 405 text: "as_todo",
401 406 searchText: "todo comment",
402 407 displayText: _gettext('TODO comment'),
403 408 hint: function(CodeMirror, data, completion) {
404 409 CodeMirror.replaceRange("", completion.from || data.from,
405 410 completion.to || data.to, "complete");
406 411
407 412 $(CommentForm.commentType).val('todo');
408 413 },
409 414 render: function(elt, data, completion) {
410 415 var el = document.createElement('div');
411 416 el.className = "pull-left";
412 417 elt.appendChild(el);
413 418
414 419 el = document.createElement('span');
415 420 el.innerHTML = completion.displayText;
416 421 elt.appendChild(el);
417 422 }
418 423 },
419 424 {
420 425 text: "as_note",
421 426 searchText: "note comment",
422 427 displayText: _gettext('Note Comment'),
423 428 hint: function(CodeMirror, data, completion) {
424 429 CodeMirror.replaceRange("", completion.from || data.from,
425 430 completion.to || data.to, "complete");
426 431
427 432 $(CommentForm.commentType).val('note');
428 433 },
429 434 render: function(elt, data, completion) {
430 435 var el = document.createElement('div');
431 436 el.className = "pull-left";
432 437 elt.appendChild(el);
433 438
434 439 el = document.createElement('span');
435 440 el.innerHTML = completion.displayText;
436 441 elt.appendChild(el);
437 442 }
438 443 }
439 444 ];
440 445
441 446 $.each(allActions, function(index, value){
442 447 var actionData = allActions[index];
443 448 if (actions.indexOf(actionData['text']) != -1) {
444 449 registeredActions.push(actionData);
445 450 }
446 451 });
447 452
448 453 return function(cm, pred) {
449 454 var cur = cm.getCursor();
450 455 var options = {
451 456 closeOnUnfocus: true,
452 457 registeredActions: registeredActions
453 458 };
454 459 setTimeout(function() {
455 460 if (!cm.state.completionActive) {
456 461 cmLog.debug('Trigger actions hinting');
457 462 CodeMirror.showHint(cm, CodeMirror.hint.actions, options);
458 463 }
459 464 }, 100);
460 465
461 466 // tell CodeMirror we didn't handle the key
462 467 // trick to trigger on a char but still complete it
463 468 return CodeMirror.Pass;
464 469 }
465 470 };
466 471
467 472 var extraKeys = {
468 473 "'@'": CodeMirrorCompleteAfter,
469 474 Tab: function(cm) {
470 475 // space indent instead of TABS
471 476 var spaces = new Array(cm.getOption("indentUnit") + 1).join(" ");
472 477 cm.replaceSelection(spaces);
473 478 }
474 479 };
475 480 // submit form on Meta-Enter
476 481 if (OSType === "mac") {
477 482 extraKeys["Cmd-Enter"] = submitForm;
483 extraKeys["Shift-Cmd-Enter"] = submitFormAsDraft;
478 484 }
479 485 else {
480 486 extraKeys["Ctrl-Enter"] = submitForm;
487 extraKeys["Shift-Ctrl-Enter"] = submitFormAsDraft;
481 488 }
482 489
483 490 if (triggerActions) {
484 491 // register triggerActions for this instance
485 492 extraKeys["'/'"] = completeActions(triggerActions);
486 493 }
487 494
488 495 var cm = CodeMirror.fromTextArea($(textAreaId).get(0), {
489 496 lineNumbers: false,
490 497 indentUnit: 4,
491 498 viewportMargin: 30,
492 499 // this is a trick to trigger some logic behind codemirror placeholder
493 500 // it influences styling and behaviour.
494 501 placeholder: " ",
495 502 extraKeys: extraKeys,
496 503 lineWrapping: true
497 504 });
498 505
499 506 cm.setSize(null, initialHeight);
500 507 cm.setOption("mode", DEFAULT_RENDERER);
501 508 CodeMirror.autoLoadMode(cm, DEFAULT_RENDERER); // load rst or markdown mode
502 509 cmLog.debug('Loading codemirror mode', DEFAULT_RENDERER);
503 510
504 511 // start listening on changes to make auto-expanded editor
505 512 cm.on("change", function (self) {
506 513 var height = initialHeight;
507 514 var lines = self.lineCount();
508 515 if (lines > 6 && lines < 20) {
509 516 height = "auto";
510 517 } else if (lines >= 20) {
511 518 height = 20 * 15;
512 519 }
513 520 self.setSize(null, height);
514 521 });
515 522
516 523 var actionHint = function(editor, options) {
517 524
518 525 var cur = editor.getCursor();
519 526 var curLine = editor.getLine(cur.line).slice(0, cur.ch);
520 527
521 528 // match only on /+1 character minimum
522 529 var tokenMatch = new RegExp('(^/\|/\)([a-zA-Z]*)$').exec(curLine);
523 530
524 531 var tokenStr = '';
525 532 if (tokenMatch !== null && tokenMatch.length > 0){
526 533 tokenStr = tokenMatch[2].strip();
527 534 }
528 535
529 536 var context = {
530 537 start: (cur.ch - tokenStr.length) - 1,
531 538 end: cur.ch,
532 539 string: tokenStr,
533 540 type: null
534 541 };
535 542
536 543 return {
537 544 list: filterActions(options.registeredActions, context),
538 545 from: CodeMirror.Pos(cur.line, context.start),
539 546 to: CodeMirror.Pos(cur.line, context.end)
540 547 };
541 548
542 549 };
543 550 CodeMirror.registerHelper("hint", "mentions", CodeMirrorMentionHint);
544 551 CodeMirror.registerHelper("hint", "actions", actionHint);
545 552 return cm;
546 553 };
547 554
548 555 var setCodeMirrorMode = function(codeMirrorInstance, mode) {
549 556 CodeMirror.autoLoadMode(codeMirrorInstance, mode);
550 557 codeMirrorInstance.setOption("mode", mode);
551 558 };
552 559
553 560 var setCodeMirrorLineWrap = function(codeMirrorInstance, line_wrap) {
554 561 codeMirrorInstance.setOption("lineWrapping", line_wrap);
555 562 };
556 563
557 564 var setCodeMirrorModeFromSelect = function(
558 565 targetSelect, targetFileInput, codeMirrorInstance, callback){
559 566
560 567 $(targetSelect).on('change', function(e) {
561 568 cmLog.debug('codemirror select2 mode change event !');
562 569 var selected = e.currentTarget;
563 570 var node = selected.options[selected.selectedIndex];
564 571 var mimetype = node.value;
565 572 cmLog.debug('picked mimetype', mimetype);
566 573 var new_mode = $(node).attr('mode');
567 574 setCodeMirrorMode(codeMirrorInstance, new_mode);
568 575 cmLog.debug('set new mode', new_mode);
569 576
570 577 //propose filename from picked mode
571 578 cmLog.debug('setting mimetype', mimetype);
572 579 var proposed_ext = getExtFromMimeType(mimetype);
573 580 cmLog.debug('file input', $(targetFileInput).val());
574 581 var file_data = getFilenameAndExt($(targetFileInput).val());
575 582 var filename = file_data.filename || 'filename1';
576 583 $(targetFileInput).val(filename + proposed_ext);
577 584 cmLog.debug('proposed file', filename + proposed_ext);
578 585
579 586
580 587 if (typeof(callback) === 'function') {
581 588 try {
582 589 cmLog.debug('running callback', callback);
583 590 callback(filename, mimetype, new_mode);
584 591 } catch (err) {
585 592 console.log('failed to run callback', callback, err);
586 593 }
587 594 }
588 595 cmLog.debug('finish iteration...');
589 596 });
590 597 };
591 598
592 599 var setCodeMirrorModeFromInput = function(
593 600 targetSelect, targetFileInput, codeMirrorInstance, callback) {
594 601
595 602 // on type the new filename set mode
596 603 $(targetFileInput).on('keyup', function(e) {
597 604 var file_data = getFilenameAndExt(this.value);
598 605 if (file_data.ext === null) {
599 606 return;
600 607 }
601 608
602 609 var mimetypes = getMimeTypeFromExt(file_data.ext, true);
603 610 cmLog.debug('mimetype from file', file_data, mimetypes);
604 611 var detected_mode;
605 612 var detected_option;
606 613 for (var i in mimetypes) {
607 614 var mt = mimetypes[i];
608 615 if (!detected_mode) {
609 616 detected_mode = detectCodeMirrorMode(this.value, mt);
610 617 }
611 618
612 619 if (!detected_option) {
613 620 cmLog.debug('#mimetype option[value="{0}"]'.format(mt));
614 621 if ($(targetSelect).find('option[value="{0}"]'.format(mt)).length) {
615 622 detected_option = mt;
616 623 }
617 624 }
618 625 }
619 626
620 627 cmLog.debug('detected mode', detected_mode);
621 628 cmLog.debug('detected option', detected_option);
622 629 if (detected_mode && detected_option){
623 630
624 631 $(targetSelect).select2("val", detected_option);
625 632 setCodeMirrorMode(codeMirrorInstance, detected_mode);
626 633
627 634 if(typeof(callback) === 'function'){
628 635 try{
629 636 cmLog.debug('running callback', callback);
630 637 var filename = file_data.filename + "." + file_data.ext;
631 638 callback(filename, detected_option, detected_mode);
632 639 }catch (err){
633 640 console.log('failed to run callback', callback, err);
634 641 }
635 642 }
636 643 }
637 644
638 645 });
639 646 };
640 647
641 648 var fillCodeMirrorOptions = function(targetSelect) {
642 649 //inject new modes, based on codeMirrors modeInfo object
643 650 var modes_select = $(targetSelect);
644 651 for (var i = 0; i < CodeMirror.modeInfo.length; i++) {
645 652 var m = CodeMirror.modeInfo[i];
646 653 var opt = new Option(m.name, m.mime);
647 654 $(opt).attr('mode', m.mode);
648 655 modes_select.append(opt);
649 656 }
650 657 };
651 658
652 659
653 660 /* markup form */
654 661 (function(mod) {
655 662
656 663 if (typeof exports == "object" && typeof module == "object") {
657 664 // CommonJS
658 665 module.exports = mod();
659 666 }
660 667 else {
661 668 // Plain browser env
662 669 (this || window).MarkupForm = mod();
663 670 }
664 671
665 672 })(function() {
666 673 "use strict";
667 674
668 675 function MarkupForm(textareaId) {
669 676 if (!(this instanceof MarkupForm)) {
670 677 return new MarkupForm(textareaId);
671 678 }
672 679
673 680 // bind the element instance to our Form
674 681 $('#' + textareaId).get(0).MarkupForm = this;
675 682
676 683 this.withSelectorId = function(selector) {
677 684 var selectorId = textareaId;
678 685 return selector + '_' + selectorId;
679 686 };
680 687
681 688 this.previewButton = this.withSelectorId('#preview-btn');
682 689 this.previewContainer = this.withSelectorId('#preview-container');
683 690
684 691 this.previewBoxSelector = this.withSelectorId('#preview-box');
685 692
686 693 this.editButton = this.withSelectorId('#edit-btn');
687 694 this.editContainer = this.withSelectorId('#edit-container');
688 695
689 696 this.cmBox = textareaId;
690 697 this.cm = initMarkupCodeMirror('#' + textareaId);
691 698
692 699 this.previewUrl = pyroutes.url('markup_preview');
693 700
694 701 // FUNCTIONS and helpers
695 702 var self = this;
696 703
697 704 this.getCmInstance = function(){
698 705 return this.cm
699 706 };
700 707
701 708 this.setPlaceholder = function(placeholder) {
702 709 var cm = this.getCmInstance();
703 710 if (cm){
704 711 cm.setOption('placeholder', placeholder);
705 712 }
706 713 };
707 714
708 715 this.initStatusChangeSelector = function(){
709 716 var formatChangeStatus = function(state, escapeMarkup) {
710 717 var originalOption = state.element;
711 718 var tmpl = '<i class="icon-circle review-status-{0}"></i><span>{1}</span>'.format($(originalOption).data('status'), escapeMarkup(state.text));
712 719 return tmpl
713 720 };
714 721 var formatResult = function(result, container, query, escapeMarkup) {
715 722 return formatChangeStatus(result, escapeMarkup);
716 723 };
717 724
718 725 var formatSelection = function(data, container, escapeMarkup) {
719 726 return formatChangeStatus(data, escapeMarkup);
720 727 };
721 728
722 729 $(this.submitForm).find(this.statusChange).select2({
723 730 placeholder: _gettext('Status Review'),
724 731 formatResult: formatResult,
725 732 formatSelection: formatSelection,
726 733 containerCssClass: "drop-menu status_box_menu",
727 734 dropdownCssClass: "drop-menu-dropdown",
728 735 dropdownAutoWidth: true,
729 736 minimumResultsForSearch: -1
730 737 });
731 738 $(this.submitForm).find(this.statusChange).on('change', function() {
732 739 var status = self.getCommentStatus();
733 740
734 741 if (status && !self.isInline()) {
735 742 $(self.submitButton).prop('disabled', false);
736 743 }
737 744
738 745 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
739 746 self.setPlaceholder(placeholderText)
740 747 })
741 748 };
742 749
743 750 // reset the text area into it's original state
744 751 this.resetMarkupFormState = function(content) {
745 752 content = content || '';
746 753
747 754 $(this.editContainer).show();
748 755 $(this.editButton).parent().addClass('active');
749 756
750 757 $(this.previewContainer).hide();
751 758 $(this.previewButton).parent().removeClass('active');
752 759
753 760 this.setActionButtonsDisabled(true);
754 761 self.cm.setValue(content);
755 762 self.cm.setOption("readOnly", false);
756 763 };
757 764
758 765 this.previewSuccessCallback = function(o) {
759 766 $(self.previewBoxSelector).html(o);
760 767 $(self.previewBoxSelector).removeClass('unloaded');
761 768
762 769 // swap buttons, making preview active
763 770 $(self.previewButton).parent().addClass('active');
764 771 $(self.editButton).parent().removeClass('active');
765 772
766 773 // unlock buttons
767 774 self.setActionButtonsDisabled(false);
768 775 };
769 776
770 777 this.setActionButtonsDisabled = function(state) {
771 778 $(this.editButton).prop('disabled', state);
772 779 $(this.previewButton).prop('disabled', state);
773 780 };
774 781
775 782 // lock preview/edit/submit buttons on load, but exclude cancel button
776 783 var excludeCancelBtn = true;
777 784 this.setActionButtonsDisabled(true);
778 785
779 786 // anonymous users don't have access to initialized CM instance
780 787 if (this.cm !== undefined){
781 788 this.cm.on('change', function(cMirror) {
782 789 if (cMirror.getValue() === "") {
783 790 self.setActionButtonsDisabled(true)
784 791 } else {
785 792 self.setActionButtonsDisabled(false)
786 793 }
787 794 });
788 795 }
789 796
790 797 $(this.editButton).on('click', function(e) {
791 798 e.preventDefault();
792 799
793 800 $(self.previewButton).parent().removeClass('active');
794 801 $(self.previewContainer).hide();
795 802
796 803 $(self.editButton).parent().addClass('active');
797 804 $(self.editContainer).show();
798 805
799 806 });
800 807
801 808 $(this.previewButton).on('click', function(e) {
802 809 e.preventDefault();
803 810 var text = self.cm.getValue();
804 811
805 812 if (text === "") {
806 813 return;
807 814 }
808 815
809 816 var postData = {
810 817 'text': text,
811 818 'renderer': templateContext.visual.default_renderer,
812 819 'csrf_token': CSRF_TOKEN
813 820 };
814 821
815 822 // lock ALL buttons on preview
816 823 self.setActionButtonsDisabled(true);
817 824
818 825 $(self.previewBoxSelector).addClass('unloaded');
819 826 $(self.previewBoxSelector).html(_gettext('Loading ...'));
820 827
821 828 $(self.editContainer).hide();
822 829 $(self.previewContainer).show();
823 830
824 831 // by default we reset state of comment preserving the text
825 832 var previewFailCallback = function(data){
826 833 alert(
827 834 "Error while submitting preview.\n" +
828 835 "Error code {0} ({1}).".format(data.status, data.statusText)
829 836 );
830 837 self.resetMarkupFormState(text)
831 838 };
832 839 _submitAjaxPOST(
833 840 self.previewUrl, postData, self.previewSuccessCallback,
834 841 previewFailCallback);
835 842
836 843 $(self.previewButton).parent().addClass('active');
837 844 $(self.editButton).parent().removeClass('active');
838 845 });
839 846
840 847 }
841 848
842 849 return MarkupForm;
843 850 });
@@ -1,1298 +1,1349 b''
1 1 // # Copyright (C) 2010-2020 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 var linkifyComments = function(comments) {
29 29 var firstCommentId = null;
30 30 if (comments) {
31 31 firstCommentId = $(comments[0]).data('comment-id');
32 32 }
33 33
34 34 if (firstCommentId){
35 35 $('#inline-comments-counter').attr('href', '#comment-' + firstCommentId);
36 36 }
37 37 };
38 38
39 39 var bindToggleButtons = function() {
40 40 $('.comment-toggle').on('click', function() {
41 41 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
42 42 });
43 43 };
44 44
45 45
46 46
47 47 var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
48 48 failHandler = failHandler || function() {};
49 49 postData = toQueryString(postData);
50 50 var request = $.ajax({
51 51 url: url,
52 52 type: 'POST',
53 53 data: postData,
54 54 headers: {'X-PARTIAL-XHR': true}
55 55 })
56 56 .done(function (data) {
57 57 successHandler(data);
58 58 })
59 59 .fail(function (data, textStatus, errorThrown) {
60 60 failHandler(data, textStatus, errorThrown)
61 61 });
62 62 return request;
63 63 };
64 64
65 65
66 66
67 67
68 68 /* Comment form for main and inline comments */
69 69 (function(mod) {
70 70
71 71 if (typeof exports == "object" && typeof module == "object") {
72 72 // CommonJS
73 73 module.exports = mod();
74 74 }
75 75 else {
76 76 // Plain browser env
77 77 (this || window).CommentForm = mod();
78 78 }
79 79
80 80 })(function() {
81 81 "use strict";
82 82
83 83 function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id) {
84 84
85 85 if (!(this instanceof CommentForm)) {
86 86 return new CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id);
87 87 }
88 88
89 89 // bind the element instance to our Form
90 90 $(formElement).get(0).CommentForm = this;
91 91
92 92 this.withLineNo = function(selector) {
93 93 var lineNo = this.lineNo;
94 94 if (lineNo === undefined) {
95 95 return selector
96 96 } else {
97 97 return selector + '_' + lineNo;
98 98 }
99 99 };
100 100
101 101 this.commitId = commitId;
102 102 this.pullRequestId = pullRequestId;
103 103 this.lineNo = lineNo;
104 104 this.initAutocompleteActions = initAutocompleteActions;
105 105
106 106 this.previewButton = this.withLineNo('#preview-btn');
107 107 this.previewContainer = this.withLineNo('#preview-container');
108 108
109 109 this.previewBoxSelector = this.withLineNo('#preview-box');
110 110
111 111 this.editButton = this.withLineNo('#edit-btn');
112 112 this.editContainer = this.withLineNo('#edit-container');
113 113 this.cancelButton = this.withLineNo('#cancel-btn');
114 114 this.commentType = this.withLineNo('#comment_type');
115 115
116 116 this.resolvesId = null;
117 117 this.resolvesActionId = null;
118 118
119 119 this.closesPr = '#close_pull_request';
120 120
121 121 this.cmBox = this.withLineNo('#text');
122 122 this.cm = initCommentBoxCodeMirror(this, this.cmBox, this.initAutocompleteActions);
123 123
124 124 this.statusChange = this.withLineNo('#change_status');
125 125
126 126 this.submitForm = formElement;
127 this.submitButton = $(this.submitForm).find('input[type="submit"]');
127
128 this.submitButton = $(this.submitForm).find('.submit-comment-action');
128 129 this.submitButtonText = this.submitButton.val();
129 130
131 this.submitDraftButton = $(this.submitForm).find('.submit-draft-action');
132 this.submitDraftButtonText = this.submitDraftButton.val();
130 133
131 134 this.previewUrl = pyroutes.url('repo_commit_comment_preview',
132 135 {'repo_name': templateContext.repo_name,
133 136 'commit_id': templateContext.commit_data.commit_id});
134 137
135 138 if (edit){
136 this.submitButtonText = _gettext('Updated Comment');
139 this.submitDraftButton.hide();
140 this.submitButtonText = _gettext('Update Comment');
137 141 $(this.commentType).prop('disabled', true);
138 142 $(this.commentType).addClass('disabled');
139 143 var editInfo =
140 144 '';
141 145 $(editInfo).insertBefore($(this.editButton).parent());
142 146 }
143 147
144 148 if (resolvesCommentId){
145 149 this.resolvesId = '#resolve_comment_{0}'.format(resolvesCommentId);
146 150 this.resolvesActionId = '#resolve_comment_action_{0}'.format(resolvesCommentId);
147 151 $(this.commentType).prop('disabled', true);
148 152 $(this.commentType).addClass('disabled');
149 153
150 154 // disable select
151 155 setTimeout(function() {
152 156 $(self.statusChange).select2('readonly', true);
153 157 }, 10);
154 158
155 159 var resolvedInfo = (
156 160 '<li class="resolve-action">' +
157 161 '<input type="hidden" id="resolve_comment_{0}" name="resolve_comment_{0}" value="{0}">' +
158 162 '<button id="resolve_comment_action_{0}" class="resolve-text btn btn-sm" onclick="return Rhodecode.comments.submitResolution({0})">{1} #{0}</button>' +
159 163 '</li>'
160 164 ).format(resolvesCommentId, _gettext('resolve comment'));
161 165 $(resolvedInfo).insertAfter($(this.commentType).parent());
162 166 }
163 167
164 168 // based on commitId, or pullRequestId decide where do we submit
165 169 // out data
166 170 if (this.commitId){
167 171 var pyurl = 'repo_commit_comment_create';
168 172 if(edit){
169 173 pyurl = 'repo_commit_comment_edit';
170 174 }
171 175 this.submitUrl = pyroutes.url(pyurl,
172 176 {'repo_name': templateContext.repo_name,
173 177 'commit_id': this.commitId,
174 178 'comment_id': comment_id});
175 179 this.selfUrl = pyroutes.url('repo_commit',
176 180 {'repo_name': templateContext.repo_name,
177 181 'commit_id': this.commitId});
178 182
179 183 } else if (this.pullRequestId) {
180 184 var pyurl = 'pullrequest_comment_create';
181 185 if(edit){
182 186 pyurl = 'pullrequest_comment_edit';
183 187 }
184 188 this.submitUrl = pyroutes.url(pyurl,
185 189 {'repo_name': templateContext.repo_name,
186 190 'pull_request_id': this.pullRequestId,
187 191 'comment_id': comment_id});
188 192 this.selfUrl = pyroutes.url('pullrequest_show',
189 193 {'repo_name': templateContext.repo_name,
190 194 'pull_request_id': this.pullRequestId});
191 195
192 196 } else {
193 197 throw new Error(
194 198 'CommentForm requires pullRequestId, or commitId to be specified.')
195 199 }
196 200
197 201 // FUNCTIONS and helpers
198 202 var self = this;
199 203
200 204 this.isInline = function(){
201 205 return this.lineNo && this.lineNo != 'general';
202 206 };
203 207
204 208 this.getCmInstance = function(){
205 209 return this.cm
206 210 };
207 211
208 212 this.setPlaceholder = function(placeholder) {
209 213 var cm = this.getCmInstance();
210 214 if (cm){
211 215 cm.setOption('placeholder', placeholder);
212 216 }
213 217 };
214 218
215 219 this.getCommentStatus = function() {
216 220 return $(this.submitForm).find(this.statusChange).val();
217 221 };
222
218 223 this.getCommentType = function() {
219 224 return $(this.submitForm).find(this.commentType).val();
220 225 };
221 226
227 this.getDraftState = function () {
228 var submitterElem = $(this.submitForm).find('input[type="submit"].submitter');
229 var data = $(submitterElem).data('isDraft');
230 return data
231 }
232
222 233 this.getResolvesId = function() {
223 234 return $(this.submitForm).find(this.resolvesId).val() || null;
224 235 };
225 236
226 237 this.getClosePr = function() {
227 238 return $(this.submitForm).find(this.closesPr).val() || null;
228 239 };
229 240
230 241 this.markCommentResolved = function(resolvedCommentId){
231 242 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolved').show();
232 243 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolve').hide();
233 244 };
234 245
235 246 this.isAllowedToSubmit = function() {
236 return !$(this.submitButton).prop('disabled');
247 var commentDisabled = $(this.submitButton).prop('disabled');
248 var draftDisabled = $(this.submitDraftButton).prop('disabled');
249 return !commentDisabled && !draftDisabled;
237 250 };
238 251
239 252 this.initStatusChangeSelector = function(){
240 253 var formatChangeStatus = function(state, escapeMarkup) {
241 254 var originalOption = state.element;
242 255 var tmpl = '<i class="icon-circle review-status-{0}"></i><span>{1}</span>'.format($(originalOption).data('status'), escapeMarkup(state.text));
243 256 return tmpl
244 257 };
245 258 var formatResult = function(result, container, query, escapeMarkup) {
246 259 return formatChangeStatus(result, escapeMarkup);
247 260 };
248 261
249 262 var formatSelection = function(data, container, escapeMarkup) {
250 263 return formatChangeStatus(data, escapeMarkup);
251 264 };
252 265
253 266 $(this.submitForm).find(this.statusChange).select2({
254 267 placeholder: _gettext('Status Review'),
255 268 formatResult: formatResult,
256 269 formatSelection: formatSelection,
257 270 containerCssClass: "drop-menu status_box_menu",
258 271 dropdownCssClass: "drop-menu-dropdown",
259 272 dropdownAutoWidth: true,
260 273 minimumResultsForSearch: -1
261 274 });
275
262 276 $(this.submitForm).find(this.statusChange).on('change', function() {
263 277 var status = self.getCommentStatus();
264 278
265 279 if (status && !self.isInline()) {
266 280 $(self.submitButton).prop('disabled', false);
281 $(self.submitDraftButton).prop('disabled', false);
267 282 }
268 283
269 284 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
270 285 self.setPlaceholder(placeholderText)
271 286 })
272 287 };
273 288
274 289 // reset the comment form into it's original state
275 290 this.resetCommentFormState = function(content) {
276 291 content = content || '';
277 292
278 293 $(this.editContainer).show();
279 294 $(this.editButton).parent().addClass('active');
280 295
281 296 $(this.previewContainer).hide();
282 297 $(this.previewButton).parent().removeClass('active');
283 298
284 299 this.setActionButtonsDisabled(true);
285 300 self.cm.setValue(content);
286 301 self.cm.setOption("readOnly", false);
287 302
288 303 if (this.resolvesId) {
289 304 // destroy the resolve action
290 305 $(this.resolvesId).parent().remove();
291 306 }
292 307 // reset closingPR flag
293 308 $('.close-pr-input').remove();
294 309
295 310 $(this.statusChange).select2('readonly', false);
296 311 };
297 312
298 this.globalSubmitSuccessCallback = function(){
313 this.globalSubmitSuccessCallback = function(comment){
299 314 // default behaviour is to call GLOBAL hook, if it's registered.
300 315 if (window.commentFormGlobalSubmitSuccessCallback !== undefined){
301 commentFormGlobalSubmitSuccessCallback();
316 commentFormGlobalSubmitSuccessCallback(comment);
302 317 }
303 318 };
304 319
305 320 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
306 321 return _submitAjaxPOST(url, postData, successHandler, failHandler);
307 322 };
308 323
309 324 // overwrite a submitHandler, we need to do it for inline comments
310 325 this.setHandleFormSubmit = function(callback) {
311 326 this.handleFormSubmit = callback;
312 327 };
313 328
314 329 // overwrite a submitSuccessHandler
315 330 this.setGlobalSubmitSuccessCallback = function(callback) {
316 331 this.globalSubmitSuccessCallback = callback;
317 332 };
318 333
319 334 // default handler for for submit for main comments
320 335 this.handleFormSubmit = function() {
321 336 var text = self.cm.getValue();
322 337 var status = self.getCommentStatus();
323 338 var commentType = self.getCommentType();
339 var isDraft = self.getDraftState();
324 340 var resolvesCommentId = self.getResolvesId();
325 341 var closePullRequest = self.getClosePr();
326 342
327 343 if (text === "" && !status) {
328 344 return;
329 345 }
330 346
331 347 var excludeCancelBtn = false;
332 348 var submitEvent = true;
333 349 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
334 350 self.cm.setOption("readOnly", true);
335 351
336 352 var postData = {
337 353 'text': text,
338 354 'changeset_status': status,
339 355 'comment_type': commentType,
340 356 'csrf_token': CSRF_TOKEN
341 357 };
342 358
343 359 if (resolvesCommentId) {
344 360 postData['resolves_comment_id'] = resolvesCommentId;
345 361 }
346 362
347 363 if (closePullRequest) {
348 364 postData['close_pull_request'] = true;
349 365 }
350 366
351 367 var submitSuccessCallback = function(o) {
352 368 // reload page if we change status for single commit.
353 369 if (status && self.commitId) {
354 370 location.reload(true);
355 371 } else {
356 372 $('#injected_page_comments').append(o.rendered_text);
357 373 self.resetCommentFormState();
358 374 timeagoActivate();
359 375 tooltipActivate();
360 376
361 377 // mark visually which comment was resolved
362 378 if (resolvesCommentId) {
363 379 self.markCommentResolved(resolvesCommentId);
364 380 }
365 381 }
366 382
367 383 // run global callback on submit
368 self.globalSubmitSuccessCallback();
384 self.globalSubmitSuccessCallback({draft: isDraft, comment_id: comment_id});
369 385
370 386 };
371 387 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
372 388 var prefix = "Error while submitting comment.\n"
373 389 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
374 390 ajaxErrorSwal(message);
375 391 self.resetCommentFormState(text);
376 392 };
377 393 self.submitAjaxPOST(
378 394 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
379 395 };
380 396
381 397 this.previewSuccessCallback = function(o) {
382 398 $(self.previewBoxSelector).html(o);
383 399 $(self.previewBoxSelector).removeClass('unloaded');
384 400
385 401 // swap buttons, making preview active
386 402 $(self.previewButton).parent().addClass('active');
387 403 $(self.editButton).parent().removeClass('active');
388 404
389 405 // unlock buttons
390 406 self.setActionButtonsDisabled(false);
391 407 };
392 408
393 409 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
394 410 excludeCancelBtn = excludeCancelBtn || false;
395 411 submitEvent = submitEvent || false;
396 412
397 413 $(this.editButton).prop('disabled', state);
398 414 $(this.previewButton).prop('disabled', state);
399 415
400 416 if (!excludeCancelBtn) {
401 417 $(this.cancelButton).prop('disabled', state);
402 418 }
403 419
404 420 var submitState = state;
405 421 if (!submitEvent && this.getCommentStatus() && !self.isInline()) {
406 422 // if the value of commit review status is set, we allow
407 423 // submit button, but only on Main form, isInline means inline
408 424 submitState = false
409 425 }
410 426
411 427 $(this.submitButton).prop('disabled', submitState);
428 $(this.submitDraftButton).prop('disabled', submitState);
429
412 430 if (submitEvent) {
431 var isDraft = self.getDraftState();
432
433 if (isDraft) {
434 $(this.submitDraftButton).val(_gettext('Saving Draft...'));
435 } else {
413 436 $(this.submitButton).val(_gettext('Submitting...'));
437 }
438
414 439 } else {
415 440 $(this.submitButton).val(this.submitButtonText);
441 $(this.submitDraftButton).val(this.submitDraftButtonText);
416 442 }
417 443
418 444 };
419 445
420 446 // lock preview/edit/submit buttons on load, but exclude cancel button
421 447 var excludeCancelBtn = true;
422 448 this.setActionButtonsDisabled(true, excludeCancelBtn);
423 449
424 450 // anonymous users don't have access to initialized CM instance
425 451 if (this.cm !== undefined){
426 452 this.cm.on('change', function(cMirror) {
427 453 if (cMirror.getValue() === "") {
428 454 self.setActionButtonsDisabled(true, excludeCancelBtn)
429 455 } else {
430 456 self.setActionButtonsDisabled(false, excludeCancelBtn)
431 457 }
432 458 });
433 459 }
434 460
435 461 $(this.editButton).on('click', function(e) {
436 462 e.preventDefault();
437 463
438 464 $(self.previewButton).parent().removeClass('active');
439 465 $(self.previewContainer).hide();
440 466
441 467 $(self.editButton).parent().addClass('active');
442 468 $(self.editContainer).show();
443 469
444 470 });
445 471
446 472 $(this.previewButton).on('click', function(e) {
447 473 e.preventDefault();
448 474 var text = self.cm.getValue();
449 475
450 476 if (text === "") {
451 477 return;
452 478 }
453 479
454 480 var postData = {
455 481 'text': text,
456 482 'renderer': templateContext.visual.default_renderer,
457 483 'csrf_token': CSRF_TOKEN
458 484 };
459 485
460 486 // lock ALL buttons on preview
461 487 self.setActionButtonsDisabled(true);
462 488
463 489 $(self.previewBoxSelector).addClass('unloaded');
464 490 $(self.previewBoxSelector).html(_gettext('Loading ...'));
465 491
466 492 $(self.editContainer).hide();
467 493 $(self.previewContainer).show();
468 494
469 495 // by default we reset state of comment preserving the text
470 496 var previewFailCallback = function(jqXHR, textStatus, errorThrown) {
471 497 var prefix = "Error while preview of comment.\n"
472 498 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
473 499 ajaxErrorSwal(message);
474 500
475 501 self.resetCommentFormState(text)
476 502 };
477 503 self.submitAjaxPOST(
478 504 self.previewUrl, postData, self.previewSuccessCallback,
479 505 previewFailCallback);
480 506
481 507 $(self.previewButton).parent().addClass('active');
482 508 $(self.editButton).parent().removeClass('active');
483 509 });
484 510
485 511 $(this.submitForm).submit(function(e) {
486 512 e.preventDefault();
487 513 var allowedToSubmit = self.isAllowedToSubmit();
488 514 if (!allowedToSubmit){
489 515 return false;
490 516 }
517
491 518 self.handleFormSubmit();
492 519 });
493 520
494 521 }
495 522
496 523 return CommentForm;
497 524 });
498 525
499 526 /* selector for comment versions */
500 527 var initVersionSelector = function(selector, initialData) {
501 528
502 529 var formatResult = function(result, container, query, escapeMarkup) {
503 530
504 531 return renderTemplate('commentVersion', {
505 532 show_disabled: true,
506 533 version: result.comment_version,
507 534 user_name: result.comment_author_username,
508 535 gravatar_url: result.comment_author_gravatar,
509 536 size: 16,
510 537 timeago_component: result.comment_created_on,
511 538 })
512 539 };
513 540
514 541 $(selector).select2({
515 542 placeholder: "Edited",
516 543 containerCssClass: "drop-menu-comment-history",
517 544 dropdownCssClass: "drop-menu-dropdown",
518 545 dropdownAutoWidth: true,
519 546 minimumResultsForSearch: -1,
520 547 data: initialData,
521 548 formatResult: formatResult,
522 549 });
523 550
524 551 $(selector).on('select2-selecting', function (e) {
525 552 // hide the mast as we later do preventDefault()
526 553 $("#select2-drop-mask").click();
527 554 e.preventDefault();
528 555 e.choice.action();
529 556 });
530 557
531 558 $(selector).on("select2-open", function() {
532 559 timeagoActivate();
533 560 });
534 561 };
535 562
536 563 /* comments controller */
537 564 var CommentsController = function() {
538 565 var mainComment = '#text';
539 566 var self = this;
540 567
541 568 this.cancelComment = function (node) {
542 569 var $node = $(node);
543 570 var edit = $(this).attr('edit');
544 571 if (edit) {
545 572 var $general_comments = null;
546 573 var $inline_comments = $node.closest('div.inline-comments');
547 574 if (!$inline_comments.length) {
548 575 $general_comments = $('#comments');
549 576 var $comment = $general_comments.parent().find('div.comment:hidden');
550 577 // show hidden general comment form
551 578 $('#cb-comment-general-form-placeholder').show();
552 579 } else {
553 580 var $comment = $inline_comments.find('div.comment:hidden');
554 581 }
555 582 $comment.show();
556 583 }
557 584 $node.closest('.comment-inline-form').remove();
558 585 return false;
559 586 };
560 587
561 588 this.showVersion = function (comment_id, comment_history_id) {
562 589
563 590 var historyViewUrl = pyroutes.url(
564 591 'repo_commit_comment_history_view',
565 592 {
566 593 'repo_name': templateContext.repo_name,
567 594 'commit_id': comment_id,
568 595 'comment_history_id': comment_history_id,
569 596 }
570 597 );
571 598 successRenderCommit = function (data) {
572 599 SwalNoAnimation.fire({
573 600 html: data,
574 601 title: '',
575 602 });
576 603 };
577 604 failRenderCommit = function () {
578 605 SwalNoAnimation.fire({
579 606 html: 'Error while loading comment history',
580 607 title: '',
581 608 });
582 609 };
583 610 _submitAjaxPOST(
584 611 historyViewUrl, {'csrf_token': CSRF_TOKEN},
585 612 successRenderCommit,
586 613 failRenderCommit
587 614 );
588 615 };
589 616
590 617 this.getLineNumber = function(node) {
591 618 var $node = $(node);
592 619 var lineNo = $node.closest('td').attr('data-line-no');
593 620 if (lineNo === undefined && $node.data('commentInline')){
594 621 lineNo = $node.data('commentLineNo')
595 622 }
596 623
597 624 return lineNo
598 625 };
599 626
600 627 this.scrollToComment = function(node, offset, outdated) {
601 628 if (offset === undefined) {
602 629 offset = 0;
603 630 }
604 631 var outdated = outdated || false;
605 632 var klass = outdated ? 'div.comment-outdated' : 'div.comment-current';
606 633
607 634 if (!node) {
608 635 node = $('.comment-selected');
609 636 if (!node.length) {
610 637 node = $('comment-current')
611 638 }
612 639 }
613 640
614 641 $wrapper = $(node).closest('div.comment');
615 642
616 643 // show hidden comment when referenced.
617 644 if (!$wrapper.is(':visible')){
618 645 $wrapper.show();
619 646 }
620 647
621 648 $comment = $(node).closest(klass);
622 649 $comments = $(klass);
623 650
624 651 $('.comment-selected').removeClass('comment-selected');
625 652
626 653 var nextIdx = $(klass).index($comment) + offset;
627 654 if (nextIdx >= $comments.length) {
628 655 nextIdx = 0;
629 656 }
630 657 var $next = $(klass).eq(nextIdx);
631 658
632 659 var $cb = $next.closest('.cb');
633 660 $cb.removeClass('cb-collapsed');
634 661
635 662 var $filediffCollapseState = $cb.closest('.filediff').prev();
636 663 $filediffCollapseState.prop('checked', false);
637 664 $next.addClass('comment-selected');
638 665 scrollToElement($next);
639 666 return false;
640 667 };
641 668
642 669 this.nextComment = function(node) {
643 670 return self.scrollToComment(node, 1);
644 671 };
645 672
646 673 this.prevComment = function(node) {
647 674 return self.scrollToComment(node, -1);
648 675 };
649 676
650 677 this.nextOutdatedComment = function(node) {
651 678 return self.scrollToComment(node, 1, true);
652 679 };
653 680
654 681 this.prevOutdatedComment = function(node) {
655 682 return self.scrollToComment(node, -1, true);
656 683 };
657 684
658 685 this._deleteComment = function(node) {
659 686 var $node = $(node);
660 687 var $td = $node.closest('td');
661 688 var $comment = $node.closest('.comment');
662 var comment_id = $comment.attr('data-comment-id');
689 var comment_id = $($comment).data('commentId');
690 var isDraft = $($comment).data('commentDraft');
663 691 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
664 692 var postData = {
665 693 'csrf_token': CSRF_TOKEN
666 694 };
667 695
668 696 $comment.addClass('comment-deleting');
669 697 $comment.hide('fast');
670 698
671 699 var success = function(response) {
672 700 $comment.remove();
673 701
674 702 if (window.updateSticky !== undefined) {
675 703 // potentially our comments change the active window size, so we
676 704 // notify sticky elements
677 705 updateSticky()
678 706 }
679 707
680 if (window.refreshAllComments !== undefined) {
708 if (window.refreshAllComments !== undefined && !isDraft) {
681 709 // if we have this handler, run it, and refresh all comments boxes
682 710 refreshAllComments()
683 711 }
684 712 return false;
685 713 };
686 714
687 715 var failure = function(jqXHR, textStatus, errorThrown) {
688 716 var prefix = "Error while deleting this comment.\n"
689 717 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
690 718 ajaxErrorSwal(message);
691 719
692 720 $comment.show('fast');
693 721 $comment.removeClass('comment-deleting');
694 722 return false;
695 723 };
696 724 ajaxPOST(url, postData, success, failure);
697 725
698 726
699 727
700 728 }
701 729
702 730 this.deleteComment = function(node) {
703 731 var $comment = $(node).closest('.comment');
704 732 var comment_id = $comment.attr('data-comment-id');
705 733
706 734 SwalNoAnimation.fire({
707 735 title: 'Delete this comment?',
708 736 icon: 'warning',
709 737 showCancelButton: true,
710 738 confirmButtonText: _gettext('Yes, delete comment #{0}!').format(comment_id),
711 739
712 740 }).then(function(result) {
713 741 if (result.value) {
714 742 self._deleteComment(node);
715 743 }
716 744 })
717 745 };
718 746
747 this._finalizeDrafts = function(commentIds) {
748 window.finalizeDrafts(commentIds)
749 }
750
751 this.finalizeDrafts = function(commentIds) {
752
753 SwalNoAnimation.fire({
754 title: _ngettext('Submit {0} draft comment', 'Submit {0} draft comments', commentIds.length).format(commentIds.length),
755 icon: 'warning',
756 showCancelButton: true,
757 confirmButtonText: _gettext('Yes, finalize drafts'),
758
759 }).then(function(result) {
760 if (result.value) {
761 self._finalizeDrafts(commentIds);
762 }
763 })
764 };
765
719 766 this.toggleWideMode = function (node) {
720 767 if ($('#content').hasClass('wrapper')) {
721 768 $('#content').removeClass("wrapper");
722 769 $('#content').addClass("wide-mode-wrapper");
723 770 $(node).addClass('btn-success');
724 771 return true
725 772 } else {
726 773 $('#content').removeClass("wide-mode-wrapper");
727 774 $('#content').addClass("wrapper");
728 775 $(node).removeClass('btn-success');
729 776 return false
730 777 }
731 778
732 779 };
733 780
734 781 this.toggleComments = function(node, show) {
735 782 var $filediff = $(node).closest('.filediff');
736 783 if (show === true) {
737 784 $filediff.removeClass('hide-comments');
738 785 } else if (show === false) {
739 786 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
740 787 $filediff.addClass('hide-comments');
741 788 } else {
742 789 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
743 790 $filediff.toggleClass('hide-comments');
744 791 }
745 792
746 793 // since we change the height of the diff container that has anchor points for upper
747 794 // sticky header, we need to tell it to re-calculate those
748 795 if (window.updateSticky !== undefined) {
749 796 // potentially our comments change the active window size, so we
750 797 // notify sticky elements
751 798 updateSticky()
752 799 }
753 800
754 801 return false;
755 802 };
756 803
757 804 this.toggleLineComments = function(node) {
758 805 self.toggleComments(node, true);
759 806 var $node = $(node);
760 807 // mark outdated comments as visible before the toggle;
761 808 $(node.closest('tr')).find('.comment-outdated').show();
762 809 $node.closest('tr').toggleClass('hide-line-comments');
763 810 };
764 811
765 812 this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId, edit, comment_id){
766 813 var pullRequestId = templateContext.pull_request_data.pull_request_id;
767 814 var commitId = templateContext.commit_data.commit_id;
768 815
769 816 var commentForm = new CommentForm(
770 817 formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId, edit, comment_id);
771 818 var cm = commentForm.getCmInstance();
772 819
773 820 if (resolvesCommentId){
774 821 placeholderText = _gettext('Leave a resolution comment, or click resolve button to resolve TODO comment #{0}').format(resolvesCommentId);
775 822 }
776 823
777 824 setTimeout(function() {
778 825 // callbacks
779 826 if (cm !== undefined) {
780 827 commentForm.setPlaceholder(placeholderText);
781 828 if (commentForm.isInline()) {
782 829 cm.focus();
783 830 cm.refresh();
784 831 }
785 832 }
786 833 }, 10);
787 834
788 835 // trigger scrolldown to the resolve comment, since it might be away
789 836 // from the clicked
790 837 if (resolvesCommentId){
791 838 var actionNode = $(commentForm.resolvesActionId).offset();
792 839
793 840 setTimeout(function() {
794 841 if (actionNode) {
795 842 $('body, html').animate({scrollTop: actionNode.top}, 10);
796 843 }
797 844 }, 100);
798 845 }
799 846
800 847 // add dropzone support
801 848 var insertAttachmentText = function (cm, attachmentName, attachmentStoreUrl, isRendered) {
802 849 var renderer = templateContext.visual.default_renderer;
803 850 if (renderer == 'rst') {
804 851 var attachmentUrl = '`#{0} <{1}>`_'.format(attachmentName, attachmentStoreUrl);
805 852 if (isRendered){
806 853 attachmentUrl = '\n.. image:: {0}'.format(attachmentStoreUrl);
807 854 }
808 855 } else if (renderer == 'markdown') {
809 856 var attachmentUrl = '[{0}]({1})'.format(attachmentName, attachmentStoreUrl);
810 857 if (isRendered){
811 858 attachmentUrl = '!' + attachmentUrl;
812 859 }
813 860 } else {
814 861 var attachmentUrl = '{}'.format(attachmentStoreUrl);
815 862 }
816 863 cm.replaceRange(attachmentUrl+'\n', CodeMirror.Pos(cm.lastLine()));
817 864
818 865 return false;
819 866 };
820 867
821 868 //see: https://www.dropzonejs.com/#configuration
822 869 var storeUrl = pyroutes.url('repo_commit_comment_attachment_upload',
823 870 {'repo_name': templateContext.repo_name,
824 871 'commit_id': templateContext.commit_data.commit_id})
825 872
826 873 var previewTmpl = $(formElement).find('.comment-attachment-uploader-template').get(0);
827 874 if (previewTmpl !== undefined){
828 875 var selectLink = $(formElement).find('.pick-attachment').get(0);
829 876 $(formElement).find('.comment-attachment-uploader').dropzone({
830 877 url: storeUrl,
831 878 headers: {"X-CSRF-Token": CSRF_TOKEN},
832 879 paramName: function () {
833 880 return "attachment"
834 881 }, // The name that will be used to transfer the file
835 882 clickable: selectLink,
836 883 parallelUploads: 1,
837 884 maxFiles: 10,
838 885 maxFilesize: templateContext.attachment_store.max_file_size_mb,
839 886 uploadMultiple: false,
840 887 autoProcessQueue: true, // if false queue will not be processed automatically.
841 888 createImageThumbnails: false,
842 889 previewTemplate: previewTmpl.innerHTML,
843 890
844 891 accept: function (file, done) {
845 892 done();
846 893 },
847 894 init: function () {
848 895
849 896 this.on("sending", function (file, xhr, formData) {
850 897 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').hide();
851 898 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').show();
852 899 });
853 900
854 901 this.on("success", function (file, response) {
855 902 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').show();
856 903 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
857 904
858 905 var isRendered = false;
859 906 var ext = file.name.split('.').pop();
860 907 var imageExts = templateContext.attachment_store.image_ext;
861 908 if (imageExts.indexOf(ext) !== -1){
862 909 isRendered = true;
863 910 }
864 911
865 912 insertAttachmentText(cm, file.name, response.repo_fqn_access_path, isRendered)
866 913 });
867 914
868 915 this.on("error", function (file, errorMessage, xhr) {
869 916 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
870 917
871 918 var error = null;
872 919
873 920 if (xhr !== undefined){
874 921 var httpStatus = xhr.status + " " + xhr.statusText;
875 922 if (xhr !== undefined && xhr.status >= 500) {
876 923 error = httpStatus;
877 924 }
878 925 }
879 926
880 927 if (error === null) {
881 928 error = errorMessage.error || errorMessage || httpStatus;
882 929 }
883 930 $(file.previewElement).find('.dz-error-message').html('ERROR: {0}'.format(error));
884 931
885 932 });
886 933 }
887 934 });
888 935 }
889 936 return commentForm;
890 937 };
891 938
892 939 this.createGeneralComment = function (lineNo, placeholderText, resolvesCommentId) {
893 940
894 941 var tmpl = $('#cb-comment-general-form-template').html();
895 942 tmpl = tmpl.format(null, 'general');
896 943 var $form = $(tmpl);
897 944
898 945 var $formPlaceholder = $('#cb-comment-general-form-placeholder');
899 946 var curForm = $formPlaceholder.find('form');
900 947 if (curForm){
901 948 curForm.remove();
902 949 }
903 950 $formPlaceholder.append($form);
904 951
905 952 var _form = $($form[0]);
906 953 var autocompleteActions = ['approve', 'reject', 'as_note', 'as_todo'];
907 954 var edit = false;
908 955 var comment_id = null;
909 956 var commentForm = this.createCommentForm(
910 957 _form, lineNo, placeholderText, autocompleteActions, resolvesCommentId, edit, comment_id);
911 958 commentForm.initStatusChangeSelector();
912 959
913 960 return commentForm;
914 961 };
915 962
916 963 this.editComment = function(node) {
917 964 var $node = $(node);
918 965 var $comment = $(node).closest('.comment');
919 var comment_id = $comment.attr('data-comment-id');
966 var comment_id = $($comment).data('commentId');
967 var isDraft = $($comment).data('commentDraft');
920 968 var $form = null
921 969
922 970 var $comments = $node.closest('div.inline-comments');
923 971 var $general_comments = null;
924 972 var lineno = null;
925 973
926 974 if($comments.length){
927 975 // inline comments setup
928 976 $form = $comments.find('.comment-inline-form');
929 977 lineno = self.getLineNumber(node)
930 978 }
931 979 else{
932 980 // general comments setup
933 981 $comments = $('#comments');
934 982 $form = $comments.find('.comment-inline-form');
935 983 lineno = $comment[0].id
936 984 $('#cb-comment-general-form-placeholder').hide();
937 985 }
938 986
939 987 this.edit = true;
940 988
941 989 if (!$form.length) {
942 990
943 991 var $filediff = $node.closest('.filediff');
944 992 $filediff.removeClass('hide-comments');
945 993 var f_path = $filediff.attr('data-f-path');
946 994
947 995 // create a new HTML from template
948 996
949 997 var tmpl = $('#cb-comment-inline-form-template').html();
950 998 tmpl = tmpl.format(escapeHtml(f_path), lineno);
951 999 $form = $(tmpl);
952 1000 $comment.after($form)
953 1001
954 1002 var _form = $($form[0]).find('form');
955 1003 var autocompleteActions = ['as_note',];
956 1004 var commentForm = this.createCommentForm(
957 1005 _form, lineno, '', autocompleteActions, resolvesCommentId,
958 1006 this.edit, comment_id);
959 1007 var old_comment_text_binary = $comment.attr('data-comment-text');
960 1008 var old_comment_text = b64DecodeUnicode(old_comment_text_binary);
961 1009 commentForm.cm.setValue(old_comment_text);
962 1010 $comment.hide();
963 1011
964 1012 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
965 1013 form: _form,
966 1014 parent: $comments,
967 1015 lineno: lineno,
968 1016 f_path: f_path}
969 1017 );
970 1018
971 1019 // set a CUSTOM submit handler for inline comments.
972 1020 commentForm.setHandleFormSubmit(function(o) {
973 1021 var text = commentForm.cm.getValue();
974 1022 var commentType = commentForm.getCommentType();
975 1023
976 1024 if (text === "") {
977 1025 return;
978 1026 }
979 1027
980 1028 if (old_comment_text == text) {
981 1029 SwalNoAnimation.fire({
982 1030 title: 'Unable to edit comment',
983 1031 html: _gettext('Comment body was not changed.'),
984 1032 });
985 1033 return;
986 1034 }
987 1035 var excludeCancelBtn = false;
988 1036 var submitEvent = true;
989 1037 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
990 1038 commentForm.cm.setOption("readOnly", true);
991 1039
992 1040 // Read last version known
993 1041 var versionSelector = $('#comment_versions_{0}'.format(comment_id));
994 1042 var version = versionSelector.data('lastVersion');
995 1043
996 1044 if (!version) {
997 1045 version = 0;
998 1046 }
999 1047
1000 1048 var postData = {
1001 1049 'text': text,
1002 1050 'f_path': f_path,
1003 1051 'line': lineno,
1004 1052 'comment_type': commentType,
1053 'draft': isDraft,
1005 1054 'version': version,
1006 1055 'csrf_token': CSRF_TOKEN
1007 1056 };
1008 1057
1009 1058 var submitSuccessCallback = function(json_data) {
1010 1059 $form.remove();
1011 1060 $comment.show();
1012 1061 var postData = {
1013 1062 'text': text,
1014 1063 'renderer': $comment.attr('data-comment-renderer'),
1015 1064 'csrf_token': CSRF_TOKEN
1016 1065 };
1017 1066
1018 1067 /* Inject new edited version selector */
1019 1068 var updateCommentVersionDropDown = function () {
1020 1069 var versionSelectId = '#comment_versions_'+comment_id;
1021 1070 var preLoadVersionData = [
1022 1071 {
1023 1072 id: json_data['comment_version'],
1024 1073 text: "v{0}".format(json_data['comment_version']),
1025 1074 action: function () {
1026 1075 Rhodecode.comments.showVersion(
1027 1076 json_data['comment_id'],
1028 1077 json_data['comment_history_id']
1029 1078 )
1030 1079 },
1031 1080 comment_version: json_data['comment_version'],
1032 1081 comment_author_username: json_data['comment_author_username'],
1033 1082 comment_author_gravatar: json_data['comment_author_gravatar'],
1034 1083 comment_created_on: json_data['comment_created_on'],
1035 1084 },
1036 1085 ]
1037 1086
1038 1087
1039 1088 if ($(versionSelectId).data('select2')) {
1040 1089 var oldData = $(versionSelectId).data('select2').opts.data.results;
1041 1090 $(versionSelectId).select2("destroy");
1042 1091 preLoadVersionData = oldData.concat(preLoadVersionData)
1043 1092 }
1044 1093
1045 1094 initVersionSelector(versionSelectId, {results: preLoadVersionData});
1046 1095
1047 1096 $comment.attr('data-comment-text', utf8ToB64(text));
1048 1097
1049 1098 var versionSelector = $('#comment_versions_'+comment_id);
1050 1099
1051 1100 // set lastVersion so we know our last edit version
1052 1101 versionSelector.data('lastVersion', json_data['comment_version'])
1053 1102 versionSelector.parent().show();
1054 1103 }
1055 1104 updateCommentVersionDropDown();
1056 1105
1057 1106 // by default we reset state of comment preserving the text
1058 1107 var failRenderCommit = function(jqXHR, textStatus, errorThrown) {
1059 1108 var prefix = "Error while editing this comment.\n"
1060 1109 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1061 1110 ajaxErrorSwal(message);
1062 1111 };
1063 1112
1064 1113 var successRenderCommit = function(o){
1065 1114 $comment.show();
1066 1115 $comment[0].lastElementChild.innerHTML = o;
1067 1116 };
1068 1117
1069 1118 var previewUrl = pyroutes.url(
1070 1119 'repo_commit_comment_preview',
1071 1120 {'repo_name': templateContext.repo_name,
1072 1121 'commit_id': templateContext.commit_data.commit_id});
1073 1122
1074 1123 _submitAjaxPOST(
1075 1124 previewUrl, postData, successRenderCommit,
1076 1125 failRenderCommit
1077 1126 );
1078 1127
1079 1128 try {
1080 1129 var html = json_data.rendered_text;
1081 1130 var lineno = json_data.line_no;
1082 1131 var target_id = json_data.target_id;
1083 1132
1084 1133 $comments.find('.cb-comment-add-button').before(html);
1085 1134
1086 1135 // run global callback on submit
1087 commentForm.globalSubmitSuccessCallback();
1136 commentForm.globalSubmitSuccessCallback({draft: isDraft, comment_id: comment_id});
1088 1137
1089 1138 } catch (e) {
1090 1139 console.error(e);
1091 1140 }
1092 1141
1093 1142 // re trigger the linkification of next/prev navigation
1094 1143 linkifyComments($('.inline-comment-injected'));
1095 1144 timeagoActivate();
1096 1145 tooltipActivate();
1097 1146
1098 1147 if (window.updateSticky !== undefined) {
1099 1148 // potentially our comments change the active window size, so we
1100 1149 // notify sticky elements
1101 1150 updateSticky()
1102 1151 }
1103 1152
1104 if (window.refreshAllComments !== undefined) {
1153 if (window.refreshAllComments !== undefined && !isDraft) {
1105 1154 // if we have this handler, run it, and refresh all comments boxes
1106 1155 refreshAllComments()
1107 1156 }
1108 1157
1109 1158 commentForm.setActionButtonsDisabled(false);
1110 1159
1111 1160 };
1112 1161
1113 1162 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
1114 1163 var prefix = "Error while editing comment.\n"
1115 1164 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1116 1165 if (jqXHR.status == 409){
1117 1166 message = 'This comment was probably changed somewhere else. Please reload the content of this comment.'
1118 1167 ajaxErrorSwal(message, 'Comment version mismatch.');
1119 1168 } else {
1120 1169 ajaxErrorSwal(message);
1121 1170 }
1122 1171
1123 1172 commentForm.resetCommentFormState(text)
1124 1173 };
1125 1174 commentForm.submitAjaxPOST(
1126 1175 commentForm.submitUrl, postData,
1127 1176 submitSuccessCallback,
1128 1177 submitFailCallback);
1129 1178 });
1130 1179 }
1131 1180
1132 1181 $form.addClass('comment-inline-form-open');
1133 1182 };
1134 1183
1135 1184 this.createComment = function(node, resolutionComment) {
1136 1185 var resolvesCommentId = resolutionComment || null;
1137 1186 var $node = $(node);
1138 1187 var $td = $node.closest('td');
1139 1188 var $form = $td.find('.comment-inline-form');
1140 1189 this.edit = false;
1141 1190
1142 1191 if (!$form.length) {
1143 1192
1144 1193 var $filediff = $node.closest('.filediff');
1145 1194 $filediff.removeClass('hide-comments');
1146 1195 var f_path = $filediff.attr('data-f-path');
1147 1196 var lineno = self.getLineNumber(node);
1148 1197 // create a new HTML from template
1149 1198 var tmpl = $('#cb-comment-inline-form-template').html();
1150 1199 tmpl = tmpl.format(escapeHtml(f_path), lineno);
1151 1200 $form = $(tmpl);
1152 1201
1153 1202 var $comments = $td.find('.inline-comments');
1154 1203 if (!$comments.length) {
1155 1204 $comments = $(
1156 1205 $('#cb-comments-inline-container-template').html());
1157 1206 $td.append($comments);
1158 1207 }
1159 1208
1160 1209 $td.find('.cb-comment-add-button').before($form);
1161 1210
1162 1211 var placeholderText = _gettext('Leave a comment on line {0}.').format(lineno);
1163 1212 var _form = $($form[0]).find('form');
1164 1213 var autocompleteActions = ['as_note', 'as_todo'];
1165 1214 var comment_id=null;
1166 1215 var commentForm = this.createCommentForm(
1167 1216 _form, lineno, placeholderText, autocompleteActions, resolvesCommentId, this.edit, comment_id);
1168 1217
1169 1218 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
1170 1219 form: _form,
1171 1220 parent: $td[0],
1172 1221 lineno: lineno,
1173 1222 f_path: f_path}
1174 1223 );
1175 1224
1176 1225 // set a CUSTOM submit handler for inline comments.
1177 1226 commentForm.setHandleFormSubmit(function(o) {
1178 1227 var text = commentForm.cm.getValue();
1179 1228 var commentType = commentForm.getCommentType();
1180 1229 var resolvesCommentId = commentForm.getResolvesId();
1230 var isDraft = commentForm.getDraftState();
1181 1231
1182 1232 if (text === "") {
1183 1233 return;
1184 1234 }
1185 1235
1186 1236 if (lineno === undefined) {
1187 1237 alert('missing line !');
1188 1238 return;
1189 1239 }
1190 1240 if (f_path === undefined) {
1191 1241 alert('missing file path !');
1192 1242 return;
1193 1243 }
1194 1244
1195 1245 var excludeCancelBtn = false;
1196 1246 var submitEvent = true;
1197 1247 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
1198 1248 commentForm.cm.setOption("readOnly", true);
1199 1249 var postData = {
1200 1250 'text': text,
1201 1251 'f_path': f_path,
1202 1252 'line': lineno,
1203 1253 'comment_type': commentType,
1254 'draft': isDraft,
1204 1255 'csrf_token': CSRF_TOKEN
1205 1256 };
1206 1257 if (resolvesCommentId){
1207 1258 postData['resolves_comment_id'] = resolvesCommentId;
1208 1259 }
1209 1260
1210 1261 var submitSuccessCallback = function(json_data) {
1211 1262 $form.remove();
1212 1263 try {
1213 1264 var html = json_data.rendered_text;
1214 1265 var lineno = json_data.line_no;
1215 1266 var target_id = json_data.target_id;
1216 1267
1217 1268 $comments.find('.cb-comment-add-button').before(html);
1218 1269
1219 1270 //mark visually which comment was resolved
1220 1271 if (resolvesCommentId) {
1221 1272 commentForm.markCommentResolved(resolvesCommentId);
1222 1273 }
1223 1274
1224 1275 // run global callback on submit
1225 commentForm.globalSubmitSuccessCallback();
1276 commentForm.globalSubmitSuccessCallback({draft: isDraft, comment_id: comment_id});
1226 1277
1227 1278 } catch (e) {
1228 1279 console.error(e);
1229 1280 }
1230 1281
1231 1282 // re trigger the linkification of next/prev navigation
1232 1283 linkifyComments($('.inline-comment-injected'));
1233 1284 timeagoActivate();
1234 1285 tooltipActivate();
1235 1286
1236 1287 if (window.updateSticky !== undefined) {
1237 1288 // potentially our comments change the active window size, so we
1238 1289 // notify sticky elements
1239 1290 updateSticky()
1240 1291 }
1241 1292
1242 if (window.refreshAllComments !== undefined) {
1293 if (window.refreshAllComments !== undefined && !isDraft) {
1243 1294 // if we have this handler, run it, and refresh all comments boxes
1244 1295 refreshAllComments()
1245 1296 }
1246 1297
1247 1298 commentForm.setActionButtonsDisabled(false);
1248 1299
1249 1300 };
1250 1301 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
1251 1302 var prefix = "Error while submitting comment.\n"
1252 1303 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1253 1304 ajaxErrorSwal(message);
1254 1305 commentForm.resetCommentFormState(text)
1255 1306 };
1256 1307 commentForm.submitAjaxPOST(
1257 1308 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
1258 1309 });
1259 1310 }
1260 1311
1261 1312 $form.addClass('comment-inline-form-open');
1262 1313 };
1263 1314
1264 1315 this.createResolutionComment = function(commentId){
1265 1316 // hide the trigger text
1266 1317 $('#resolve-comment-{0}'.format(commentId)).hide();
1267 1318
1268 1319 var comment = $('#comment-'+commentId);
1269 1320 var commentData = comment.data();
1270 1321 if (commentData.commentInline) {
1271 1322 this.createComment(comment, commentId)
1272 1323 } else {
1273 1324 Rhodecode.comments.createGeneralComment('general', "$placeholder", commentId)
1274 1325 }
1275 1326
1276 1327 return false;
1277 1328 };
1278 1329
1279 1330 this.submitResolution = function(commentId){
1280 1331 var form = $('#resolve_comment_{0}'.format(commentId)).closest('form');
1281 1332 var commentForm = form.get(0).CommentForm;
1282 1333
1283 1334 var cm = commentForm.getCmInstance();
1284 1335 var renderer = templateContext.visual.default_renderer;
1285 1336 if (renderer == 'rst'){
1286 1337 var commentUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentForm.selfUrl);
1287 1338 } else if (renderer == 'markdown') {
1288 1339 var commentUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentForm.selfUrl);
1289 1340 } else {
1290 1341 var commentUrl = '{1}#comment-{0}'.format(commentId, commentForm.selfUrl);
1291 1342 }
1292 1343
1293 1344 cm.setValue(_gettext('TODO from comment {0} was fixed.').format(commentUrl));
1294 1345 form.submit();
1295 1346 return false;
1296 1347 };
1297 1348
1298 1349 };
@@ -1,1254 +1,1262 b''
1 1 ## -*- coding: utf-8 -*-
2 2
3 3 <%!
4 4 from rhodecode.lib import html_filters
5 5 %>
6 6
7 7 <%inherit file="root.mako"/>
8 8
9 9 <%include file="/ejs_templates/templates.html"/>
10 10
11 11 <div class="outerwrapper">
12 12 <!-- HEADER -->
13 13 <div class="header">
14 14 <div id="header-inner" class="wrapper">
15 15 <div id="logo">
16 16 <div class="logo-wrapper">
17 17 <a href="${h.route_path('home')}"><img src="${h.asset('images/rhodecode-logo-white-60x60.png')}" alt="RhodeCode"/></a>
18 18 </div>
19 19 % if c.rhodecode_name:
20 20 <div class="branding">
21 21 <a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
22 22 </div>
23 23 % endif
24 24 </div>
25 25 <!-- MENU BAR NAV -->
26 26 ${self.menu_bar_nav()}
27 27 <!-- END MENU BAR NAV -->
28 28 </div>
29 29 </div>
30 30 ${self.menu_bar_subnav()}
31 31 <!-- END HEADER -->
32 32
33 33 <!-- CONTENT -->
34 34 <div id="content" class="wrapper">
35 35
36 36 <rhodecode-toast id="notifications"></rhodecode-toast>
37 37
38 38 <div class="main">
39 39 ${next.main()}
40 40 </div>
41 41
42 42 </div>
43 43 <!-- END CONTENT -->
44 44
45 45 </div>
46 46
47 47 <!-- FOOTER -->
48 48 <div id="footer">
49 49 <div id="footer-inner" class="title wrapper">
50 50 <div>
51 51 <% sid = 'block' if request.GET.get('showrcid') else 'none' %>
52 52
53 53 <p class="footer-link-right">
54 54 <a class="grey-link-action" href="${h.route_path('home', _query={'showrcid': 1})}">
55 55 RhodeCode
56 56 % if c.visual.show_version:
57 57 ${c.rhodecode_version}
58 58 % endif
59 59 ${c.rhodecode_edition}
60 60 </a> |
61 61
62 62 % if c.visual.rhodecode_support_url:
63 63 <a class="grey-link-action" href="${c.visual.rhodecode_support_url}" target="_blank">${_('Support')}</a> |
64 64 <a class="grey-link-action" href="https://docs.rhodecode.com" target="_blank">${_('Documentation')}</a>
65 65 % endif
66 66
67 67 </p>
68 68
69 69 <p class="server-instance" style="display:${sid}">
70 70 ## display hidden instance ID if specially defined
71 71 &copy; 2010-${h.datetime.today().year}, <a href="${h.route_url('rhodecode_official')}" target="_blank">RhodeCode GmbH</a>. All rights reserved.
72 72 % if c.rhodecode_instanceid:
73 73 ${_('RhodeCode instance id: {}').format(c.rhodecode_instanceid)}
74 74 % endif
75 75 </p>
76 76 </div>
77 77 </div>
78 78 </div>
79 79
80 80 <!-- END FOOTER -->
81 81
82 82 ### MAKO DEFS ###
83 83
84 84 <%def name="menu_bar_subnav()">
85 85 </%def>
86 86
87 87 <%def name="breadcrumbs(class_='breadcrumbs')">
88 88 <div class="${class_}">
89 89 ${self.breadcrumbs_links()}
90 90 </div>
91 91 </%def>
92 92
93 93 <%def name="admin_menu(active=None)">
94 94
95 95 <div id="context-bar">
96 96 <div class="wrapper">
97 97 <div class="title">
98 98 <div class="title-content">
99 99 <div class="title-main">
100 100 % if c.is_super_admin:
101 101 ${_('Super-admin Panel')}
102 102 % else:
103 103 ${_('Delegated Admin Panel')}
104 104 % endif
105 105 </div>
106 106 </div>
107 107 </div>
108 108
109 109 <ul id="context-pages" class="navigation horizontal-list">
110 110
111 111 ## super-admin case
112 112 % if c.is_super_admin:
113 113 <li class="${h.is_active('audit_logs', active)}"><a href="${h.route_path('admin_audit_logs')}">${_('Admin audit logs')}</a></li>
114 114 <li class="${h.is_active('repositories', active)}"><a href="${h.route_path('repos')}">${_('Repositories')}</a></li>
115 115 <li class="${h.is_active('repository_groups', active)}"><a href="${h.route_path('repo_groups')}">${_('Repository groups')}</a></li>
116 116 <li class="${h.is_active('users', active)}"><a href="${h.route_path('users')}">${_('Users')}</a></li>
117 117 <li class="${h.is_active('user_groups', active)}"><a href="${h.route_path('user_groups')}">${_('User groups')}</a></li>
118 118 <li class="${h.is_active('permissions', active)}"><a href="${h.route_path('admin_permissions_application')}">${_('Permissions')}</a></li>
119 119 <li class="${h.is_active('authentication', active)}"><a href="${h.route_path('auth_home', traverse='')}">${_('Authentication')}</a></li>
120 120 <li class="${h.is_active('integrations', active)}"><a href="${h.route_path('global_integrations_home')}">${_('Integrations')}</a></li>
121 121 <li class="${h.is_active('defaults', active)}"><a href="${h.route_path('admin_defaults_repositories')}">${_('Defaults')}</a></li>
122 122 <li class="${h.is_active('settings', active)}"><a href="${h.route_path('admin_settings')}">${_('Settings')}</a></li>
123 123
124 124 ## delegated admin
125 125 % elif c.is_delegated_admin:
126 126 <%
127 127 repositories=c.auth_user.repositories_admin or c.can_create_repo
128 128 repository_groups=c.auth_user.repository_groups_admin or c.can_create_repo_group
129 129 user_groups=c.auth_user.user_groups_admin or c.can_create_user_group
130 130 %>
131 131
132 132 %if repositories:
133 133 <li class="${h.is_active('repositories', active)} local-admin-repos"><a href="${h.route_path('repos')}">${_('Repositories')}</a></li>
134 134 %endif
135 135 %if repository_groups:
136 136 <li class="${h.is_active('repository_groups', active)} local-admin-repo-groups"><a href="${h.route_path('repo_groups')}">${_('Repository groups')}</a></li>
137 137 %endif
138 138 %if user_groups:
139 139 <li class="${h.is_active('user_groups', active)} local-admin-user-groups"><a href="${h.route_path('user_groups')}">${_('User groups')}</a></li>
140 140 %endif
141 141 % endif
142 142 </ul>
143 143
144 144 </div>
145 145 <div class="clear"></div>
146 146 </div>
147 147 </%def>
148 148
149 149 <%def name="dt_info_panel(elements)">
150 150 <dl class="dl-horizontal">
151 151 %for dt, dd, title, show_items in elements:
152 152 <dt>${dt}:</dt>
153 153 <dd title="${h.tooltip(title)}">
154 154 %if callable(dd):
155 155 ## allow lazy evaluation of elements
156 156 ${dd()}
157 157 %else:
158 158 ${dd}
159 159 %endif
160 160 %if show_items:
161 161 <span class="btn-collapse" data-toggle="item-${h.md5_safe(dt)[:6]}-details">${_('Show More')} </span>
162 162 %endif
163 163 </dd>
164 164
165 165 %if show_items:
166 166 <div class="collapsable-content" data-toggle="item-${h.md5_safe(dt)[:6]}-details" style="display: none">
167 167 %for item in show_items:
168 168 <dt></dt>
169 169 <dd>${item}</dd>
170 170 %endfor
171 171 </div>
172 172 %endif
173 173
174 174 %endfor
175 175 </dl>
176 176 </%def>
177 177
178 178 <%def name="tr_info_entry(element)">
179 179 <% key, val, title, show_items = element %>
180 180
181 181 <tr>
182 182 <td style="vertical-align: top">${key}</td>
183 183 <td title="${h.tooltip(title)}">
184 184 %if callable(val):
185 185 ## allow lazy evaluation of elements
186 186 ${val()}
187 187 %else:
188 188 ${val}
189 189 %endif
190 190 %if show_items:
191 191 <div class="collapsable-content" data-toggle="item-${h.md5_safe(val)[:6]}-details" style="display: none">
192 192 % for item in show_items:
193 193 <dt></dt>
194 194 <dd>${item}</dd>
195 195 % endfor
196 196 </div>
197 197 %endif
198 198 </td>
199 199 <td style="vertical-align: top">
200 200 %if show_items:
201 201 <span class="btn-collapse" data-toggle="item-${h.md5_safe(val)[:6]}-details">${_('Show More')} </span>
202 202 %endif
203 203 </td>
204 204 </tr>
205 205
206 206 </%def>
207 207
208 208 <%def name="gravatar(email, size=16, tooltip=False, tooltip_alt=None, user=None, extra_class=None)">
209 209 <%
210 210 if size > 16:
211 211 gravatar_class = ['gravatar','gravatar-large']
212 212 else:
213 213 gravatar_class = ['gravatar']
214 214
215 215 data_hovercard_url = ''
216 216 data_hovercard_alt = tooltip_alt.replace('<', '&lt;').replace('>', '&gt;') if tooltip_alt else ''
217 217
218 218 if tooltip:
219 219 gravatar_class += ['tooltip-hovercard']
220 220 if extra_class:
221 221 gravatar_class += extra_class
222 222 if tooltip and user:
223 223 if user.username == h.DEFAULT_USER:
224 224 gravatar_class.pop(-1)
225 225 else:
226 226 data_hovercard_url = request.route_path('hovercard_user', user_id=getattr(user, 'user_id', ''))
227 227 gravatar_class = ' '.join(gravatar_class)
228 228
229 229 %>
230 230 <%doc>
231 231 TODO: johbo: For now we serve double size images to make it smooth
232 232 for retina. This is how it worked until now. Should be replaced
233 233 with a better solution at some point.
234 234 </%doc>
235 235
236 236 <img class="${gravatar_class}" height="${size}" width="${size}" data-hovercard-url="${data_hovercard_url}" data-hovercard-alt="${data_hovercard_alt}" src="${h.gravatar_url(email, size * 2)}" />
237 237 </%def>
238 238
239 239
240 240 <%def name="gravatar_with_user(contact, size=16, show_disabled=False, tooltip=False, _class='rc-user')">
241 241 <%
242 242 email = h.email_or_none(contact)
243 243 rc_user = h.discover_user(contact)
244 244 %>
245 245
246 246 <div class="${_class}">
247 247 ${self.gravatar(email, size, tooltip=tooltip, tooltip_alt=contact, user=rc_user)}
248 248 <span class="${('user user-disabled' if show_disabled else 'user')}">
249 249 ${h.link_to_user(rc_user or contact)}
250 250 </span>
251 251 </div>
252 252 </%def>
253 253
254 254
255 255 <%def name="user_group_icon(user_group=None, size=16, tooltip=False)">
256 256 <%
257 257 if (size > 16):
258 258 gravatar_class = 'icon-user-group-alt'
259 259 else:
260 260 gravatar_class = 'icon-user-group-alt'
261 261
262 262 if tooltip:
263 263 gravatar_class += ' tooltip-hovercard'
264 264
265 265 data_hovercard_url = request.route_path('hovercard_user_group', user_group_id=user_group.users_group_id)
266 266 %>
267 267 <%doc>
268 268 TODO: johbo: For now we serve double size images to make it smooth
269 269 for retina. This is how it worked until now. Should be replaced
270 270 with a better solution at some point.
271 271 </%doc>
272 272
273 273 <i style="font-size: ${size}px" class="${gravatar_class} x-icon-size-${size}" data-hovercard-url="${data_hovercard_url}"></i>
274 274 </%def>
275 275
276 276 <%def name="repo_page_title(repo_instance)">
277 277 <div class="title-content repo-title">
278 278
279 279 <div class="title-main">
280 280 ## SVN/HG/GIT icons
281 281 %if h.is_hg(repo_instance):
282 282 <i class="icon-hg"></i>
283 283 %endif
284 284 %if h.is_git(repo_instance):
285 285 <i class="icon-git"></i>
286 286 %endif
287 287 %if h.is_svn(repo_instance):
288 288 <i class="icon-svn"></i>
289 289 %endif
290 290
291 291 ## public/private
292 292 %if repo_instance.private:
293 293 <i class="icon-repo-private"></i>
294 294 %else:
295 295 <i class="icon-repo-public"></i>
296 296 %endif
297 297
298 298 ## repo name with group name
299 299 ${h.breadcrumb_repo_link(repo_instance)}
300 300
301 301 ## Context Actions
302 302 <div class="pull-right">
303 303 %if c.rhodecode_user.username != h.DEFAULT_USER:
304 304 <a href="${h.route_path('atom_feed_home', repo_name=c.rhodecode_db_repo.repo_uid, _query=dict(auth_token=c.rhodecode_user.feed_token))}" title="${_('RSS Feed')}" class="btn btn-sm"><i class="icon-rss-sign"></i>RSS</a>
305 305
306 306 <a href="#WatchRepo" onclick="toggleFollowingRepo(this, templateContext.repo_id); return false" title="${_('Watch this Repository and actions on it in your personalized journal')}" class="btn btn-sm ${('watching' if c.repository_is_user_following else '')}">
307 307 % if c.repository_is_user_following:
308 308 <i class="icon-eye-off"></i>${_('Unwatch')}
309 309 % else:
310 310 <i class="icon-eye"></i>${_('Watch')}
311 311 % endif
312 312
313 313 </a>
314 314 %else:
315 315 <a href="${h.route_path('atom_feed_home', repo_name=c.rhodecode_db_repo.repo_uid)}" title="${_('RSS Feed')}" class="btn btn-sm"><i class="icon-rss-sign"></i>RSS</a>
316 316 %endif
317 317 </div>
318 318
319 319 </div>
320 320
321 321 ## FORKED
322 322 %if repo_instance.fork:
323 323 <p class="discreet">
324 324 <i class="icon-code-fork"></i> ${_('Fork of')}
325 325 ${h.link_to_if(c.has_origin_repo_read_perm,repo_instance.fork.repo_name, h.route_path('repo_summary', repo_name=repo_instance.fork.repo_name))}
326 326 </p>
327 327 %endif
328 328
329 329 ## IMPORTED FROM REMOTE
330 330 %if repo_instance.clone_uri:
331 331 <p class="discreet">
332 332 <i class="icon-code-fork"></i> ${_('Clone from')}
333 333 <a href="${h.safe_str(h.hide_credentials(repo_instance.clone_uri))}">${h.hide_credentials(repo_instance.clone_uri)}</a>
334 334 </p>
335 335 %endif
336 336
337 337 ## LOCKING STATUS
338 338 %if repo_instance.locked[0]:
339 339 <p class="locking_locked discreet">
340 340 <i class="icon-repo-lock"></i>
341 341 ${_('Repository locked by %(user)s') % {'user': h.person_by_id(repo_instance.locked[0])}}
342 342 </p>
343 343 %elif repo_instance.enable_locking:
344 344 <p class="locking_unlocked discreet">
345 345 <i class="icon-repo-unlock"></i>
346 346 ${_('Repository not locked. Pull repository to lock it.')}
347 347 </p>
348 348 %endif
349 349
350 350 </div>
351 351 </%def>
352 352
353 353 <%def name="repo_menu(active=None)">
354 354 <%
355 355 ## determine if we have "any" option available
356 356 can_lock = h.HasRepoPermissionAny('repository.write','repository.admin')(c.repo_name) and c.rhodecode_db_repo.enable_locking
357 357 has_actions = can_lock
358 358
359 359 %>
360 360 % if c.rhodecode_db_repo.archived:
361 361 <div class="alert alert-warning text-center">
362 362 <strong>${_('This repository has been archived. It is now read-only.')}</strong>
363 363 </div>
364 364 % endif
365 365
366 366 <!--- REPO CONTEXT BAR -->
367 367 <div id="context-bar">
368 368 <div class="wrapper">
369 369
370 370 <div class="title">
371 371 ${self.repo_page_title(c.rhodecode_db_repo)}
372 372 </div>
373 373
374 374 <ul id="context-pages" class="navigation horizontal-list">
375 375 <li class="${h.is_active('summary', active)}"><a class="menulink" href="${h.route_path('repo_summary_explicit', repo_name=c.repo_name)}"><div class="menulabel">${_('Summary')}</div></a></li>
376 376 <li class="${h.is_active('commits', active)}"><a class="menulink" href="${h.route_path('repo_commits', repo_name=c.repo_name)}"><div class="menulabel">${_('Commits')}</div></a></li>
377 377 <li class="${h.is_active('files', active)}"><a class="menulink" href="${h.repo_files_by_ref_url(c.repo_name, c.rhodecode_db_repo.repo_type, f_path='', ref_name=c.rhodecode_db_repo.landing_ref_name, commit_id='tip', query={'at':c.rhodecode_db_repo.landing_ref_name})}"><div class="menulabel">${_('Files')}</div></a></li>
378 378 <li class="${h.is_active('compare', active)}"><a class="menulink" href="${h.route_path('repo_compare_select',repo_name=c.repo_name)}"><div class="menulabel">${_('Compare')}</div></a></li>
379 379
380 380 ## TODO: anderson: ideally it would have a function on the scm_instance "enable_pullrequest() and enable_fork()"
381 381 %if c.rhodecode_db_repo.repo_type in ['git','hg']:
382 382 <li class="${h.is_active('showpullrequest', active)}">
383 383 <a class="menulink" href="${h.route_path('pullrequest_show_all', repo_name=c.repo_name)}" title="${h.tooltip(_('Show Pull Requests for %s') % c.repo_name)}">
384 384 <div class="menulabel">
385 385 ${_('Pull Requests')} <span class="menulink-counter">${c.repository_pull_requests}</span>
386 386 </div>
387 387 </a>
388 388 </li>
389 389 %endif
390 390
391 391 <li class="${h.is_active('artifacts', active)}">
392 392 <a class="menulink" href="${h.route_path('repo_artifacts_list',repo_name=c.repo_name)}">
393 393 <div class="menulabel">
394 394 ${_('Artifacts')} <span class="menulink-counter">${c.repository_artifacts}</span>
395 395 </div>
396 396 </a>
397 397 </li>
398 398
399 399 %if not c.rhodecode_db_repo.archived and h.HasRepoPermissionAll('repository.admin')(c.repo_name):
400 400 <li class="${h.is_active('settings', active)}"><a class="menulink" href="${h.route_path('edit_repo',repo_name=c.repo_name)}"><div class="menulabel">${_('Repository Settings')}</div></a></li>
401 401 %endif
402 402
403 403 <li class="${h.is_active('options', active)}">
404 404 % if has_actions:
405 405 <a class="menulink dropdown">
406 406 <div class="menulabel">${_('Options')}<div class="show_more"></div></div>
407 407 </a>
408 408 <ul class="submenu">
409 409 %if can_lock:
410 410 %if c.rhodecode_db_repo.locked[0]:
411 411 <li><a class="locking_del" href="${h.route_path('repo_edit_toggle_locking',repo_name=c.repo_name)}">${_('Unlock Repository')}</a></li>
412 412 %else:
413 413 <li><a class="locking_add" href="${h.route_path('repo_edit_toggle_locking',repo_name=c.repo_name)}">${_('Lock Repository')}</a></li>
414 414 %endif
415 415 %endif
416 416 </ul>
417 417 % endif
418 418 </li>
419 419
420 420 </ul>
421 421 </div>
422 422 <div class="clear"></div>
423 423 </div>
424 424
425 425 <!--- REPO END CONTEXT BAR -->
426 426
427 427 </%def>
428 428
429 429 <%def name="repo_group_page_title(repo_group_instance)">
430 430 <div class="title-content">
431 431 <div class="title-main">
432 432 ## Repository Group icon
433 433 <i class="icon-repo-group"></i>
434 434
435 435 ## repo name with group name
436 436 ${h.breadcrumb_repo_group_link(repo_group_instance)}
437 437 </div>
438 438
439 439 <%namespace name="dt" file="/data_table/_dt_elements.mako"/>
440 440 <div class="repo-group-desc discreet">
441 441 ${dt.repo_group_desc(repo_group_instance.description_safe, repo_group_instance.personal, c.visual.stylify_metatags)}
442 442 </div>
443 443
444 444 </div>
445 445 </%def>
446 446
447 447
448 448 <%def name="repo_group_menu(active=None)">
449 449 <%
450 450 gr_name = c.repo_group.group_name if c.repo_group else None
451 451 # create repositories with write permission on group is set to true
452 452 group_admin = h.HasRepoGroupPermissionAny('group.admin')(gr_name, 'group admin index page')
453 453
454 454 %>
455 455
456 456
457 457 <!--- REPO GROUP CONTEXT BAR -->
458 458 <div id="context-bar">
459 459 <div class="wrapper">
460 460 <div class="title">
461 461 ${self.repo_group_page_title(c.repo_group)}
462 462 </div>
463 463
464 464 <ul id="context-pages" class="navigation horizontal-list">
465 465 <li class="${h.is_active('home', active)}">
466 466 <a class="menulink" href="${h.route_path('repo_group_home', repo_group_name=c.repo_group.group_name)}"><div class="menulabel">${_('Group Home')}</div></a>
467 467 </li>
468 468 % if c.is_super_admin or group_admin:
469 469 <li class="${h.is_active('settings', active)}">
470 470 <a class="menulink" href="${h.route_path('edit_repo_group',repo_group_name=c.repo_group.group_name)}" title="${_('You have admin right to this group, and can edit it')}"><div class="menulabel">${_('Group Settings')}</div></a>
471 471 </li>
472 472 % endif
473 473
474 474 </ul>
475 475 </div>
476 476 <div class="clear"></div>
477 477 </div>
478 478
479 479 <!--- REPO GROUP CONTEXT BAR -->
480 480
481 481 </%def>
482 482
483 483
484 484 <%def name="usermenu(active=False)">
485 485 <%
486 486 not_anonymous = c.rhodecode_user.username != h.DEFAULT_USER
487 487
488 488 gr_name = c.repo_group.group_name if (hasattr(c, 'repo_group') and c.repo_group) else None
489 489 # create repositories with write permission on group is set to true
490 490
491 491 can_fork = c.is_super_admin or h.HasPermissionAny('hg.fork.repository')()
492 492 create_on_write = h.HasPermissionAny('hg.create.write_on_repogroup.true')()
493 493 group_write = h.HasRepoGroupPermissionAny('group.write')(gr_name, 'can write into group index page')
494 494 group_admin = h.HasRepoGroupPermissionAny('group.admin')(gr_name, 'group admin index page')
495 495
496 496 can_create_repos = c.is_super_admin or c.can_create_repo
497 497 can_create_repo_groups = c.is_super_admin or c.can_create_repo_group
498 498
499 499 can_create_repos_in_group = c.is_super_admin or group_admin or (group_write and create_on_write)
500 500 can_create_repo_groups_in_group = c.is_super_admin or group_admin
501 501 %>
502 502
503 503 % if not_anonymous:
504 504 <%
505 505 default_target_group = dict()
506 506 if c.rhodecode_user.personal_repo_group:
507 507 default_target_group = dict(parent_group=c.rhodecode_user.personal_repo_group.group_id)
508 508 %>
509 509
510 510 ## create action
511 511 <li>
512 512 <a href="#create-actions" onclick="return false;" class="menulink childs">
513 513 <i class="icon-plus-circled"></i>
514 514 </a>
515 515
516 516 <div class="action-menu submenu">
517 517
518 518 <ol>
519 519 ## scope of within a repository
520 520 % if hasattr(c, 'rhodecode_db_repo') and c.rhodecode_db_repo:
521 521 <li class="submenu-title">${_('This Repository')}</li>
522 522 <li>
523 523 <a href="${h.route_path('pullrequest_new',repo_name=c.repo_name)}">${_('Create Pull Request')}</a>
524 524 </li>
525 525 % if can_fork:
526 526 <li>
527 527 <a href="${h.route_path('repo_fork_new',repo_name=c.repo_name,_query=default_target_group)}">${_('Fork this repository')}</a>
528 528 </li>
529 529 % endif
530 530 % endif
531 531
532 532 ## scope of within repository groups
533 533 % if hasattr(c, 'repo_group') and c.repo_group and (can_create_repos_in_group or can_create_repo_groups_in_group):
534 534 <li class="submenu-title">${_('This Repository Group')}</li>
535 535
536 536 % if can_create_repos_in_group:
537 537 <li>
538 538 <a href="${h.route_path('repo_new',_query=dict(parent_group=c.repo_group.group_id))}">${_('New Repository')}</a>
539 539 </li>
540 540 % endif
541 541
542 542 % if can_create_repo_groups_in_group:
543 543 <li>
544 544 <a href="${h.route_path('repo_group_new',_query=dict(parent_group=c.repo_group.group_id))}">${_(u'New Repository Group')}</a>
545 545 </li>
546 546 % endif
547 547 % endif
548 548
549 549 ## personal group
550 550 % if c.rhodecode_user.personal_repo_group:
551 551 <li class="submenu-title">Personal Group</li>
552 552
553 553 <li>
554 554 <a href="${h.route_path('repo_new',_query=dict(parent_group=c.rhodecode_user.personal_repo_group.group_id))}" >${_('New Repository')} </a>
555 555 </li>
556 556
557 557 <li>
558 558 <a href="${h.route_path('repo_group_new',_query=dict(parent_group=c.rhodecode_user.personal_repo_group.group_id))}">${_('New Repository Group')} </a>
559 559 </li>
560 560 % endif
561 561
562 562 ## Global actions
563 563 <li class="submenu-title">RhodeCode</li>
564 564 % if can_create_repos:
565 565 <li>
566 566 <a href="${h.route_path('repo_new')}" >${_('New Repository')}</a>
567 567 </li>
568 568 % endif
569 569
570 570 % if can_create_repo_groups:
571 571 <li>
572 572 <a href="${h.route_path('repo_group_new')}" >${_(u'New Repository Group')}</a>
573 573 </li>
574 574 % endif
575 575
576 576 <li>
577 577 <a href="${h.route_path('gists_new')}">${_(u'New Gist')}</a>
578 578 </li>
579 579
580 580 </ol>
581 581
582 582 </div>
583 583 </li>
584 584
585 585 ## notifications
586 586 <li>
587 587 <a class="${('empty' if c.unread_notifications == 0 else '')}" href="${h.route_path('notifications_show_all')}">
588 588 ${c.unread_notifications}
589 589 </a>
590 590 </li>
591 591 % endif
592 592
593 593 ## USER MENU
594 594 <li id="quick_login_li" class="${'active' if active else ''}">
595 595 % if c.rhodecode_user.username == h.DEFAULT_USER:
596 596 <a id="quick_login_link" class="menulink childs" href="${h.route_path('login', _query={'came_from': h.current_route_path(request)})}">
597 597 ${gravatar(c.rhodecode_user.email, 20)}
598 598 <span class="user">
599 599 <span>${_('Sign in')}</span>
600 600 </span>
601 601 </a>
602 602 % else:
603 603 ## logged in user
604 604 <a id="quick_login_link" class="menulink childs">
605 605 ${gravatar(c.rhodecode_user.email, 20)}
606 606 <span class="user">
607 607 <span class="menu_link_user">${c.rhodecode_user.username}</span>
608 608 <div class="show_more"></div>
609 609 </span>
610 610 </a>
611 611 ## subnav with menu for logged in user
612 612 <div class="user-menu submenu">
613 613 <div id="quick_login">
614 614 %if c.rhodecode_user.username != h.DEFAULT_USER:
615 615 <div class="">
616 616 <div class="big_gravatar">${gravatar(c.rhodecode_user.email, 48)}</div>
617 617 <div class="full_name">${c.rhodecode_user.full_name_or_username}</div>
618 618 <div class="email">${c.rhodecode_user.email}</div>
619 619 </div>
620 620 <div class="">
621 621 <ol class="links">
622 622 <li>${h.link_to(_(u'My account'),h.route_path('my_account_profile'))}</li>
623 623 % if c.rhodecode_user.personal_repo_group:
624 624 <li>${h.link_to(_(u'My personal group'), h.route_path('repo_group_home', repo_group_name=c.rhodecode_user.personal_repo_group.group_name))}</li>
625 625 % endif
626 626 <li>${h.link_to(_(u'Pull Requests'), h.route_path('my_account_pullrequests'))}</li>
627 627
628 628 % if c.debug_style:
629 629 <li>
630 630 <a class="menulink" title="${_('Style')}" href="${h.route_path('debug_style_home')}">
631 631 <div class="menulabel">${_('[Style]')}</div>
632 632 </a>
633 633 </li>
634 634 % endif
635 635
636 636 ## bookmark-items
637 637 <li class="bookmark-items">
638 638 ${_('Bookmarks')}
639 639 <div class="pull-right">
640 640 <a href="${h.route_path('my_account_bookmarks')}">
641 641
642 642 <i class="icon-cog"></i>
643 643 </a>
644 644 </div>
645 645 </li>
646 646 % if not c.bookmark_items:
647 647 <li>
648 648 <a href="${h.route_path('my_account_bookmarks')}">${_('No Bookmarks yet.')}</a>
649 649 </li>
650 650 % endif
651 651 % for item in c.bookmark_items:
652 652 <li>
653 653 % if item.repository:
654 654 <div>
655 655 <a class="bookmark-item" href="${h.route_path('my_account_goto_bookmark', bookmark_id=item.position)}">
656 656 <code>${item.position}</code>
657 657 % if item.repository.repo_type == 'hg':
658 658 <i class="icon-hg" title="${_('Repository')}" style="font-size: 16px"></i>
659 659 % elif item.repository.repo_type == 'git':
660 660 <i class="icon-git" title="${_('Repository')}" style="font-size: 16px"></i>
661 661 % elif item.repository.repo_type == 'svn':
662 662 <i class="icon-svn" title="${_('Repository')}" style="font-size: 16px"></i>
663 663 % endif
664 664 ${(item.title or h.shorter(item.repository.repo_name, 30))}
665 665 </a>
666 666 </div>
667 667 % elif item.repository_group:
668 668 <div>
669 669 <a class="bookmark-item" href="${h.route_path('my_account_goto_bookmark', bookmark_id=item.position)}">
670 670 <code>${item.position}</code>
671 671 <i class="icon-repo-group" title="${_('Repository group')}" style="font-size: 14px"></i>
672 672 ${(item.title or h.shorter(item.repository_group.group_name, 30))}
673 673 </a>
674 674 </div>
675 675 % else:
676 676 <a class="bookmark-item" href="${h.route_path('my_account_goto_bookmark', bookmark_id=item.position)}">
677 677 <code>${item.position}</code>
678 678 ${item.title}
679 679 </a>
680 680 % endif
681 681 </li>
682 682 % endfor
683 683
684 684 <li class="logout">
685 685 ${h.secure_form(h.route_path('logout'), request=request)}
686 686 ${h.submit('log_out', _(u'Sign Out'),class_="btn btn-primary")}
687 687 ${h.end_form()}
688 688 </li>
689 689 </ol>
690 690 </div>
691 691 %endif
692 692 </div>
693 693 </div>
694 694
695 695 % endif
696 696 </li>
697 697 </%def>
698 698
699 699 <%def name="menu_items(active=None)">
700 700 <%
701 701 notice_messages, notice_level = c.rhodecode_user.get_notice_messages()
702 702 notice_display = 'none' if len(notice_messages) == 0 else ''
703 703 %>
704 704
705 705 <ul id="quick" class="main_nav navigation horizontal-list">
706 706 ## notice box for important system messages
707 707 <li style="display: ${notice_display}">
708 708 <a class="notice-box" href="#openNotice" onclick="$('.notice-messages-container').toggle(); return false">
709 709 <div class="menulabel-notice ${notice_level}" >
710 710 ${len(notice_messages)}
711 711 </div>
712 712 </a>
713 713 </li>
714 714 <div class="notice-messages-container" style="display: none">
715 715 <div class="notice-messages">
716 716 <table class="rctable">
717 717 % for notice in notice_messages:
718 718 <tr id="notice-message-${notice['msg_id']}" class="notice-message-${notice['level']}">
719 719 <td style="vertical-align: text-top; width: 20px">
720 720 <i class="tooltip icon-info notice-color-${notice['level']}" title="${notice['level']}"></i>
721 721 </td>
722 722 <td>
723 723 <span><i class="icon-plus-squared cursor-pointer" onclick="$('#notice-${notice['msg_id']}').toggle()"></i> </span>
724 724 ${notice['subject']}
725 725
726 726 <div id="notice-${notice['msg_id']}" style="display: none">
727 727 ${h.render(notice['body'], renderer='markdown')}
728 728 </div>
729 729 </td>
730 730 <td style="vertical-align: text-top; width: 35px;">
731 731 <a class="tooltip" title="${_('dismiss')}" href="#dismiss" onclick="dismissNotice(${notice['msg_id']});return false">
732 732 <i class="icon-remove icon-filled-red"></i>
733 733 </a>
734 734 </td>
735 735 </tr>
736 736
737 737 % endfor
738 738 </table>
739 739 </div>
740 740 </div>
741 741 ## Main filter
742 742 <li>
743 743 <div class="menulabel main_filter_box">
744 744 <div class="main_filter_input_box">
745 745 <ul class="searchItems">
746 746
747 747 <li class="searchTag searchTagIcon">
748 748 <i class="icon-search"></i>
749 749 </li>
750 750
751 751 % if c.template_context['search_context']['repo_id']:
752 752 <li class="searchTag searchTagFilter searchTagHidable" >
753 753 ##<a href="${h.route_path('search_repo',repo_name=c.template_context['search_context']['repo_name'])}">
754 754 <span class="tag">
755 755 This repo
756 756 <a href="#removeGoToFilter" onclick="removeGoToFilter(); return false"><i class="icon-cancel-circled"></i></a>
757 757 </span>
758 758 ##</a>
759 759 </li>
760 760 % elif c.template_context['search_context']['repo_group_id']:
761 761 <li class="searchTag searchTagFilter searchTagHidable">
762 762 ##<a href="${h.route_path('search_repo_group',repo_group_name=c.template_context['search_context']['repo_group_name'])}">
763 763 <span class="tag">
764 764 This group
765 765 <a href="#removeGoToFilter" onclick="removeGoToFilter(); return false"><i class="icon-cancel-circled"></i></a>
766 766 </span>
767 767 ##</a>
768 768 </li>
769 769 % endif
770 770
771 771 <li class="searchTagInput">
772 772 <input class="main_filter_input" id="main_filter" size="25" type="text" name="main_filter" placeholder="${_('search / go to...')}" value="" />
773 773 </li>
774 774 <li class="searchTag searchTagHelp">
775 775 <a href="#showFilterHelp" onclick="showMainFilterBox(); return false">?</a>
776 776 </li>
777 777 </ul>
778 778 </div>
779 779 </div>
780 780
781 781 <div id="main_filter_help" style="display: none">
782 782 - Use '/' key to quickly access this field.
783 783
784 784 - Enter a name of repository, or repository group for quick search.
785 785
786 786 - Prefix query to allow special search:
787 787
788 788 <strong>user:</strong>admin, to search for usernames, always global
789 789
790 790 <strong>user_group:</strong>devops, to search for user groups, always global
791 791
792 792 <strong>pr:</strong>303, to search for pull request number, title, or description, always global
793 793
794 794 <strong>commit:</strong>efced4, to search for commits, scoped to repositories or groups
795 795
796 796 <strong>file:</strong>models.py, to search for file paths, scoped to repositories or groups
797 797
798 798 % if c.template_context['search_context']['repo_id']:
799 799 For advanced full text search visit: <a href="${h.route_path('search_repo',repo_name=c.template_context['search_context']['repo_name'])}">repository search</a>
800 800 % elif c.template_context['search_context']['repo_group_id']:
801 801 For advanced full text search visit: <a href="${h.route_path('search_repo_group',repo_group_name=c.template_context['search_context']['repo_group_name'])}">repository group search</a>
802 802 % else:
803 803 For advanced full text search visit: <a href="${h.route_path('search')}">global search</a>
804 804 % endif
805 805 </div>
806 806 </li>
807 807
808 808 ## ROOT MENU
809 809 <li class="${h.is_active('home', active)}">
810 810 <a class="menulink" title="${_('Home')}" href="${h.route_path('home')}">
811 811 <div class="menulabel">${_('Home')}</div>
812 812 </a>
813 813 </li>
814 814
815 815 %if c.rhodecode_user.username != h.DEFAULT_USER:
816 816 <li class="${h.is_active('journal', active)}">
817 817 <a class="menulink" title="${_('Show activity journal')}" href="${h.route_path('journal')}">
818 818 <div class="menulabel">${_('Journal')}</div>
819 819 </a>
820 820 </li>
821 821 %else:
822 822 <li class="${h.is_active('journal', active)}">
823 823 <a class="menulink" title="${_('Show Public activity journal')}" href="${h.route_path('journal_public')}">
824 824 <div class="menulabel">${_('Public journal')}</div>
825 825 </a>
826 826 </li>
827 827 %endif
828 828
829 829 <li class="${h.is_active('gists', active)}">
830 830 <a class="menulink childs" title="${_('Show Gists')}" href="${h.route_path('gists_show')}">
831 831 <div class="menulabel">${_('Gists')}</div>
832 832 </a>
833 833 </li>
834 834
835 835 % if c.is_super_admin or c.is_delegated_admin:
836 836 <li class="${h.is_active('admin', active)}">
837 837 <a class="menulink childs" title="${_('Admin settings')}" href="${h.route_path('admin_home')}">
838 838 <div class="menulabel">${_('Admin')} </div>
839 839 </a>
840 840 </li>
841 841 % endif
842 842
843 843 ## render extra user menu
844 844 ${usermenu(active=(active=='my_account'))}
845 845
846 846 </ul>
847 847
848 848 <script type="text/javascript">
849 849 var visualShowPublicIcon = "${c.visual.show_public_icon}" == "True";
850 850
851 851 var formatRepoResult = function(result, container, query, escapeMarkup) {
852 852 return function(data, escapeMarkup) {
853 853 if (!data.repo_id){
854 854 return data.text; // optgroup text Repositories
855 855 }
856 856
857 857 var tmpl = '';
858 858 var repoType = data['repo_type'];
859 859 var repoName = data['text'];
860 860
861 861 if(data && data.type == 'repo'){
862 862 if(repoType === 'hg'){
863 863 tmpl += '<i class="icon-hg"></i> ';
864 864 }
865 865 else if(repoType === 'git'){
866 866 tmpl += '<i class="icon-git"></i> ';
867 867 }
868 868 else if(repoType === 'svn'){
869 869 tmpl += '<i class="icon-svn"></i> ';
870 870 }
871 871 if(data['private']){
872 872 tmpl += '<i class="icon-lock" ></i> ';
873 873 }
874 874 else if(visualShowPublicIcon){
875 875 tmpl += '<i class="icon-unlock-alt"></i> ';
876 876 }
877 877 }
878 878 tmpl += escapeMarkup(repoName);
879 879 return tmpl;
880 880
881 881 }(result, escapeMarkup);
882 882 };
883 883
884 884 var formatRepoGroupResult = function(result, container, query, escapeMarkup) {
885 885 return function(data, escapeMarkup) {
886 886 if (!data.repo_group_id){
887 887 return data.text; // optgroup text Repositories
888 888 }
889 889
890 890 var tmpl = '';
891 891 var repoGroupName = data['text'];
892 892
893 893 if(data){
894 894
895 895 tmpl += '<i class="icon-repo-group"></i> ';
896 896
897 897 }
898 898 tmpl += escapeMarkup(repoGroupName);
899 899 return tmpl;
900 900
901 901 }(result, escapeMarkup);
902 902 };
903 903
904 904 var escapeRegExChars = function (value) {
905 905 return value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
906 906 };
907 907
908 908 var getRepoIcon = function(repo_type) {
909 909 if (repo_type === 'hg') {
910 910 return '<i class="icon-hg"></i> ';
911 911 }
912 912 else if (repo_type === 'git') {
913 913 return '<i class="icon-git"></i> ';
914 914 }
915 915 else if (repo_type === 'svn') {
916 916 return '<i class="icon-svn"></i> ';
917 917 }
918 918 return ''
919 919 };
920 920
921 921 var autocompleteMainFilterFormatResult = function (data, value, org_formatter) {
922 922
923 923 if (value.split(':').length === 2) {
924 924 value = value.split(':')[1]
925 925 }
926 926
927 927 var searchType = data['type'];
928 928 var searchSubType = data['subtype'];
929 929 var valueDisplay = data['value_display'];
930 930 var valueIcon = data['value_icon'];
931 931
932 932 var pattern = '(' + escapeRegExChars(value) + ')';
933 933
934 934 valueDisplay = Select2.util.escapeMarkup(valueDisplay);
935 935
936 936 // highlight match
937 937 if (searchType != 'text') {
938 938 valueDisplay = valueDisplay.replace(new RegExp(pattern, 'gi'), '<strong>$1<\/strong>');
939 939 }
940 940
941 941 var icon = '';
942 942
943 943 if (searchType === 'hint') {
944 944 icon += '<i class="icon-repo-group"></i> ';
945 945 }
946 946 // full text search/hints
947 947 else if (searchType === 'search') {
948 948 if (valueIcon === undefined) {
949 949 icon += '<i class="icon-more"></i> ';
950 950 } else {
951 951 icon += valueIcon + ' ';
952 952 }
953 953
954 954 if (searchSubType !== undefined && searchSubType == 'repo') {
955 955 valueDisplay += '<div class="pull-right tag">repository</div>';
956 956 }
957 957 else if (searchSubType !== undefined && searchSubType == 'repo_group') {
958 958 valueDisplay += '<div class="pull-right tag">repo group</div>';
959 959 }
960 960 }
961 961 // repository
962 962 else if (searchType === 'repo') {
963 963
964 964 var repoIcon = getRepoIcon(data['repo_type']);
965 965 icon += repoIcon;
966 966
967 967 if (data['private']) {
968 968 icon += '<i class="icon-lock" ></i> ';
969 969 }
970 970 else if (visualShowPublicIcon) {
971 971 icon += '<i class="icon-unlock-alt"></i> ';
972 972 }
973 973 }
974 974 // repository groups
975 975 else if (searchType === 'repo_group') {
976 976 icon += '<i class="icon-repo-group"></i> ';
977 977 }
978 978 // user group
979 979 else if (searchType === 'user_group') {
980 980 icon += '<i class="icon-group"></i> ';
981 981 }
982 982 // user
983 983 else if (searchType === 'user') {
984 984 icon += '<img class="gravatar" src="{0}"/>'.format(data['icon_link']);
985 985 }
986 986 // pull request
987 987 else if (searchType === 'pull_request') {
988 988 icon += '<i class="icon-merge"></i> ';
989 989 }
990 990 // commit
991 991 else if (searchType === 'commit') {
992 992 var repo_data = data['repo_data'];
993 993 var repoIcon = getRepoIcon(repo_data['repository_type']);
994 994 if (repoIcon) {
995 995 icon += repoIcon;
996 996 } else {
997 997 icon += '<i class="icon-tag"></i>';
998 998 }
999 999 }
1000 1000 // file
1001 1001 else if (searchType === 'file') {
1002 1002 var repo_data = data['repo_data'];
1003 1003 var repoIcon = getRepoIcon(repo_data['repository_type']);
1004 1004 if (repoIcon) {
1005 1005 icon += repoIcon;
1006 1006 } else {
1007 1007 icon += '<i class="icon-tag"></i>';
1008 1008 }
1009 1009 }
1010 1010 // generic text
1011 1011 else if (searchType === 'text') {
1012 1012 icon = '';
1013 1013 }
1014 1014
1015 1015 var tmpl = '<div class="ac-container-wrap">{0}{1}</div>';
1016 1016 return tmpl.format(icon, valueDisplay);
1017 1017 };
1018 1018
1019 1019 var handleSelect = function(element, suggestion) {
1020 1020 if (suggestion.type === "hint") {
1021 1021 // we skip action
1022 1022 $('#main_filter').focus();
1023 1023 }
1024 1024 else if (suggestion.type === "text") {
1025 1025 // we skip action
1026 1026 $('#main_filter').focus();
1027 1027
1028 1028 } else {
1029 1029 window.location = suggestion['url'];
1030 1030 }
1031 1031 };
1032 1032
1033 1033 var autocompleteMainFilterResult = function (suggestion, originalQuery, queryLowerCase) {
1034 1034 if (queryLowerCase.split(':').length === 2) {
1035 1035 queryLowerCase = queryLowerCase.split(':')[1]
1036 1036 }
1037 1037 if (suggestion.type === "text") {
1038 1038 // special case we don't want to "skip" display for
1039 1039 return true
1040 1040 }
1041 1041 return suggestion.value_display.toLowerCase().indexOf(queryLowerCase) !== -1;
1042 1042 };
1043 1043
1044 1044 var cleanContext = {
1045 1045 repo_view_type: null,
1046 1046
1047 1047 repo_id: null,
1048 1048 repo_name: "",
1049 1049
1050 1050 repo_group_id: null,
1051 1051 repo_group_name: null
1052 1052 };
1053 1053 var removeGoToFilter = function () {
1054 1054 $('.searchTagHidable').hide();
1055 1055 $('#main_filter').autocomplete(
1056 1056 'setOptions', {params:{search_context: cleanContext}});
1057 1057 };
1058 1058
1059 1059 $('#main_filter').autocomplete({
1060 1060 serviceUrl: pyroutes.url('goto_switcher_data'),
1061 1061 params: {
1062 1062 "search_context": templateContext.search_context
1063 1063 },
1064 1064 minChars:2,
1065 1065 maxHeight:400,
1066 1066 deferRequestBy: 300, //miliseconds
1067 1067 tabDisabled: true,
1068 1068 autoSelectFirst: false,
1069 1069 containerClass: 'autocomplete-qfilter-suggestions',
1070 1070 formatResult: autocompleteMainFilterFormatResult,
1071 1071 lookupFilter: autocompleteMainFilterResult,
1072 1072 onSelect: function (element, suggestion) {
1073 1073 handleSelect(element, suggestion);
1074 1074 return false;
1075 1075 },
1076 1076 onSearchError: function (element, query, jqXHR, textStatus, errorThrown) {
1077 1077 if (jqXHR !== 'abort') {
1078 1078 var message = formatErrorMessage(jqXHR, textStatus, errorThrown);
1079 1079 SwalNoAnimation.fire({
1080 1080 icon: 'error',
1081 1081 title: _gettext('Error during search operation'),
1082 1082 html: '<span style="white-space: pre-line">{0}</span>'.format(message),
1083 1083 }).then(function(result) {
1084 1084 window.location.reload();
1085 1085 })
1086 1086 }
1087 1087 },
1088 1088 onSearchStart: function (params) {
1089 1089 $('.searchTag.searchTagIcon').html('<i class="icon-spin animate-spin"></i>')
1090 1090 },
1091 1091 onSearchComplete: function (query, suggestions) {
1092 1092 $('.searchTag.searchTagIcon').html('<i class="icon-search"></i>')
1093 1093 },
1094 1094 });
1095 1095
1096 1096 showMainFilterBox = function () {
1097 1097 $('#main_filter_help').toggle();
1098 1098 };
1099 1099
1100 1100 $('#main_filter').on('keydown.autocomplete', function (e) {
1101 1101
1102 1102 var BACKSPACE = 8;
1103 1103 var el = $(e.currentTarget);
1104 1104 if(e.which === BACKSPACE){
1105 1105 var inputVal = el.val();
1106 1106 if (inputVal === ""){
1107 1107 removeGoToFilter()
1108 1108 }
1109 1109 }
1110 1110 });
1111 1111
1112 1112 var dismissNotice = function(noticeId) {
1113 1113
1114 1114 var url = pyroutes.url('user_notice_dismiss',
1115 1115 {"user_id": templateContext.rhodecode_user.user_id});
1116 1116
1117 1117 var postData = {
1118 1118 'csrf_token': CSRF_TOKEN,
1119 1119 'notice_id': noticeId,
1120 1120 };
1121 1121
1122 1122 var success = function(response) {
1123 1123 $('#notice-message-' + noticeId).remove();
1124 1124 return false;
1125 1125 };
1126 1126 var failure = function(data, textStatus, xhr) {
1127 1127 alert("error processing request: " + textStatus);
1128 1128 return false;
1129 1129 };
1130 1130 ajaxPOST(url, postData, success, failure);
1131 1131 }
1132 1132
1133 1133 var hideLicenseWarning = function () {
1134 1134 var fingerprint = templateContext.session_attrs.license_fingerprint;
1135 1135 storeUserSessionAttr('rc_user_session_attr.hide_license_warning', fingerprint);
1136 1136 $('#notifications').hide();
1137 1137 }
1138 1138
1139 1139 var hideLicenseError = function () {
1140 1140 var fingerprint = templateContext.session_attrs.license_fingerprint;
1141 1141 storeUserSessionAttr('rc_user_session_attr.hide_license_error', fingerprint);
1142 1142 $('#notifications').hide();
1143 1143 }
1144 1144
1145 1145 </script>
1146 1146 <script src="${h.asset('js/rhodecode/base/keyboard-bindings.js', ver=c.rhodecode_version_hash)}"></script>
1147 1147 </%def>
1148 1148
1149 1149 <div class="modal" id="help_kb" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
1150 1150 <div class="modal-dialog">
1151 1151 <div class="modal-content">
1152 1152 <div class="modal-header">
1153 1153 <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
1154 1154 <h4 class="modal-title" id="myModalLabel">${_('Keyboard shortcuts')}</h4>
1155 1155 </div>
1156 1156 <div class="modal-body">
1157 1157 <div class="block-left">
1158 1158 <table class="keyboard-mappings">
1159 1159 <tbody>
1160 1160 <tr>
1161 1161 <th></th>
1162 1162 <th>${_('Site-wide shortcuts')}</th>
1163 1163 </tr>
1164 1164 <%
1165 1165 elems = [
1166 1166 ('/', 'Use quick search box'),
1167 1167 ('g h', 'Goto home page'),
1168 1168 ('g g', 'Goto my private gists page'),
1169 1169 ('g G', 'Goto my public gists page'),
1170 1170 ('g 0-9', 'Goto bookmarked items from 0-9'),
1171 1171 ('n r', 'New repository page'),
1172 1172 ('n g', 'New gist page'),
1173 1173 ]
1174 1174 %>
1175 1175 %for key, desc in elems:
1176 1176 <tr>
1177 1177 <td class="keys">
1178 1178 <span class="key tag">${key}</span>
1179 1179 </td>
1180 1180 <td>${desc}</td>
1181 1181 </tr>
1182 1182 %endfor
1183 1183 </tbody>
1184 1184 </table>
1185 1185 </div>
1186 1186 <div class="block-left">
1187 1187 <table class="keyboard-mappings">
1188 1188 <tbody>
1189 1189 <tr>
1190 1190 <th></th>
1191 1191 <th>${_('Repositories')}</th>
1192 1192 </tr>
1193 1193 <%
1194 1194 elems = [
1195 1195 ('g s', 'Goto summary page'),
1196 1196 ('g c', 'Goto changelog page'),
1197 1197 ('g f', 'Goto files page'),
1198 1198 ('g F', 'Goto files page with file search activated'),
1199 1199 ('g p', 'Goto pull requests page'),
1200 1200 ('g o', 'Goto repository settings'),
1201 1201 ('g O', 'Goto repository access permissions settings'),
1202 1202 ('t s', 'Toggle sidebar on some pages'),
1203 1203 ]
1204 1204 %>
1205 1205 %for key, desc in elems:
1206 1206 <tr>
1207 1207 <td class="keys">
1208 1208 <span class="key tag">${key}</span>
1209 1209 </td>
1210 1210 <td>${desc}</td>
1211 1211 </tr>
1212 1212 %endfor
1213 1213 </tbody>
1214 1214 </table>
1215 1215 </div>
1216 1216 </div>
1217 1217 <div class="modal-footer">
1218 1218 </div>
1219 1219 </div><!-- /.modal-content -->
1220 1220 </div><!-- /.modal-dialog -->
1221 1221 </div><!-- /.modal -->
1222 1222
1223 1223
1224 1224 <script type="text/javascript">
1225 1225 (function () {
1226 1226 "use sctrict";
1227 1227
1228 // details block auto-hide menu
1229 $(document).mouseup(function(e) {
1230 var container = $('.details-inline-block');
1231 if (!container.is(e.target) && container.has(e.target).length === 0) {
1232 $('.details-inline-block[open]').removeAttr('open')
1233 }
1234 });
1235
1228 1236 var $sideBar = $('.right-sidebar');
1229 1237 var expanded = $sideBar.hasClass('right-sidebar-expanded');
1230 1238 var sidebarState = templateContext.session_attrs.sidebarState;
1231 1239 var sidebarEnabled = $('aside.right-sidebar').get(0);
1232 1240
1233 1241 if (sidebarState === 'expanded') {
1234 1242 expanded = true
1235 1243 } else if (sidebarState === 'collapsed') {
1236 1244 expanded = false
1237 1245 }
1238 1246 if (sidebarEnabled) {
1239 1247 // show sidebar since it's hidden on load
1240 1248 $('.right-sidebar').show();
1241 1249
1242 1250 // init based on set initial class, or if defined user session attrs
1243 1251 if (expanded) {
1244 1252 window.expandSidebar();
1245 1253 window.updateStickyHeader();
1246 1254
1247 1255 } else {
1248 1256 window.collapseSidebar();
1249 1257 window.updateStickyHeader();
1250 1258 }
1251 1259 }
1252 1260 })()
1253 1261
1254 1262 </script>
@@ -1,147 +1,151 b''
1 1 ## snippet for sidebar elements
2 2 ## usage:
3 3 ## <%namespace name="sidebar" file="/base/sidebar.mako"/>
4 4 ## ${sidebar.comments_table()}
5 5 <%namespace name="base" file="/base/base.mako"/>
6 6
7 7 <%def name="comments_table(comments, counter_num, todo_comments=False, existing_ids=None, is_pr=True)">
8 8 <%
9 9 if todo_comments:
10 10 cls_ = 'todos-content-table'
11 11 def sorter(entry):
12 12 user_id = entry.author.user_id
13 13 resolved = '1' if entry.resolved else '0'
14 14 if user_id == c.rhodecode_user.user_id:
15 15 # own comments first
16 16 user_id = 0
17 17 return '{}'.format(str(entry.comment_id).zfill(10000))
18 18 else:
19 19 cls_ = 'comments-content-table'
20 20 def sorter(entry):
21 21 user_id = entry.author.user_id
22 22 return '{}'.format(str(entry.comment_id).zfill(10000))
23 23
24 24 existing_ids = existing_ids or []
25 25
26 26 %>
27 27
28 28 <table class="todo-table ${cls_}" data-total-count="${len(comments)}" data-counter="${counter_num}">
29 29
30 30 % for loop_obj, comment_obj in h.looper(reversed(sorted(comments, key=sorter))):
31 31 <%
32 32 display = ''
33 33 _cls = ''
34 ## Extra precaution to not show drafts in the sidebar for todo/comments
35 if comment_obj.draft:
36 continue
34 37 %>
35 38
39
36 40 <%
37 41 comment_ver_index = comment_obj.get_index_version(getattr(c, 'versions', []))
38 42 prev_comment_ver_index = 0
39 43 if loop_obj.previous:
40 44 prev_comment_ver_index = loop_obj.previous.get_index_version(getattr(c, 'versions', []))
41 45
42 46 ver_info = None
43 47 if getattr(c, 'versions', []):
44 48 ver_info = c.versions[comment_ver_index-1] if comment_ver_index else None
45 49 %>
46 50 <% hidden_at_ver = comment_obj.outdated_at_version_js(c.at_version_num) %>
47 51 <% is_from_old_ver = comment_obj.older_than_version_js(c.at_version_num) %>
48 52 <%
49 53 if (prev_comment_ver_index > comment_ver_index):
50 54 comments_ver_divider = comment_ver_index
51 55 else:
52 56 comments_ver_divider = None
53 57 %>
54 58
55 59 % if todo_comments:
56 60 % if comment_obj.resolved:
57 61 <% _cls = 'resolved-todo' %>
58 62 <% display = 'none' %>
59 63 % endif
60 64 % else:
61 65 ## SKIP TODOs we display them in other area
62 66 % if comment_obj.is_todo:
63 67 <% display = 'none' %>
64 68 % endif
65 69 ## Skip outdated comments
66 70 % if comment_obj.outdated:
67 71 <% display = 'none' %>
68 72 <% _cls = 'hidden-comment' %>
69 73 % endif
70 74 % endif
71 75
72 76 % if not todo_comments and comments_ver_divider:
73 77 <tr class="old-comments-marker">
74 78 <td colspan="3">
75 79 % if ver_info:
76 80 <code>v${comments_ver_divider} ${h.age_component(ver_info.created_on, time_is_local=True, tooltip=False)}</code>
77 81 % else:
78 82 <code>v${comments_ver_divider}</code>
79 83 % endif
80 84 </td>
81 85 </tr>
82 86
83 87 % endif
84 88
85 89 <tr class="${_cls}" style="display: ${display};" data-sidebar-comment-id="${comment_obj.comment_id}">
86 90 <td class="td-todo-number">
87 91 <%
88 92 version_info = ''
89 93 if is_pr:
90 94 version_info = (' made in older version (v{})'.format(comment_ver_index) if is_from_old_ver == 'true' else ' made in this version')
91 95 %>
92 96 ## new comments, since refresh
93 97 % if existing_ids and comment_obj.comment_id not in existing_ids:
94 98 <div class="tooltip" style="position: absolute; left: 8px; color: #682668" title="New comment">
95 99 !
96 100 </div>
97 101 % endif
98 102
99 103 <%
100 104 data = h.json.dumps({
101 105 'comment_id': comment_obj.comment_id,
102 106 'version_info': version_info,
103 107 'file_name': comment_obj.f_path,
104 108 'line_no': comment_obj.line_no,
105 109 'outdated': comment_obj.outdated,
106 110 'inline': comment_obj.is_inline,
107 111 'is_todo': comment_obj.is_todo,
108 112 'created_on': h.format_date(comment_obj.created_on),
109 113 'datetime': '{}{}'.format(comment_obj.created_on, h.get_timezone(comment_obj.created_on, time_is_local=True)),
110 114 'review_status': (comment_obj.review_status or '')
111 115 })
112 116
113 117 if comment_obj.outdated:
114 118 icon = 'icon-comment-toggle'
115 119 elif comment_obj.is_inline:
116 120 icon = 'icon-code'
117 121 else:
118 122 icon = 'icon-comment'
119 123 %>
120 124
121 125 <i id="commentHovercard${comment_obj.comment_id}"
122 126 class="${icon} tooltip-hovercard"
123 127 data-hovercard-url="javascript:sidebarComment(${comment_obj.comment_id})"
124 128 data-comment-json-b64='${h.b64(data)}'>
125 129 </i>
126 130
127 131 </td>
128 132
129 133 <td class="td-todo-gravatar">
130 134 ${base.gravatar(comment_obj.author.email, 16, user=comment_obj.author, tooltip=True, extra_class=['no-margin'])}
131 135 </td>
132 136 <td class="todo-comment-text-wrapper">
133 137 <div class="todo-comment-text ${('todo-resolved' if comment_obj.resolved else '')}">
134 138 <a class="${('todo-resolved' if comment_obj.resolved else '')} permalink"
135 139 href="#comment-${comment_obj.comment_id}"
136 140 onclick="return Rhodecode.comments.scrollToComment($('#comment-${comment_obj.comment_id}'), 0, ${hidden_at_ver})">
137 141
138 142 ${h.chop_at_smart(comment_obj.text, '\n', suffix_if_chopped='...')}
139 143 </a>
140 144 </div>
141 145 </td>
142 146 </tr>
143 147 % endfor
144 148
145 149 </table>
146 150
147 151 </%def> No newline at end of file
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now