diff --git a/grunt_config.json b/grunt_config.json
--- a/grunt_config.json
+++ b/grunt_config.json
@@ -42,6 +42,8 @@
"<%= dirs.js.src %>/bootstrap.js",
"<%= dirs.js.src %>/i18n_utils.js",
"<%= dirs.js.src %>/deform.js",
+ "<%= dirs.js.src %>/ejs.js",
+ "<%= dirs.js.src %>/ejs_templates/utils.js",
"<%= dirs.js.src %>/plugins/jquery.pjax.js",
"<%= dirs.js.src %>/plugins/jquery.dataTables.js",
"<%= dirs.js.src %>/plugins/flavoured_checkbox.js",
diff --git a/rhodecode/__init__.py b/rhodecode/__init__.py
--- a/rhodecode/__init__.py
+++ b/rhodecode/__init__.py
@@ -51,7 +51,7 @@ PYRAMID_SETTINGS = {}
EXTENSIONS = {}
__version__ = ('.'.join((str(each) for each in VERSION[:3])))
-__dbversion__ = 83 # defines current db version for migrations
+__dbversion__ = 85 # defines current db version for migrations
__platform__ = platform.system()
__license__ = 'AGPLv3, and Commercial License'
__author__ = 'RhodeCode GmbH'
diff --git a/rhodecode/api/tests/test_get_pull_request.py b/rhodecode/api/tests/test_get_pull_request.py
--- a/rhodecode/api/tests/test_get_pull_request.py
+++ b/rhodecode/api/tests/test_get_pull_request.py
@@ -108,7 +108,7 @@ class TestGetPullRequest(object):
'reasons': reasons,
'review_status': st[0][1].status if st else 'not_reviewed',
}
- for reviewer, reasons, mandatory, st in
+ for obj, reviewer, reasons, mandatory, st in
pull_request.reviewers_statuses()
]
}
diff --git a/rhodecode/api/tests/test_update_pull_request.py b/rhodecode/api/tests/test_update_pull_request.py
--- a/rhodecode/api/tests/test_update_pull_request.py
+++ b/rhodecode/api/tests/test_update_pull_request.py
@@ -133,7 +133,7 @@ class TestUpdatePullRequest(object):
removed = [a.username]
pull_request = pr_util.create_pull_request(
- reviewers=[(a.username, ['added via API'], False)])
+ reviewers=[(a.username, ['added via API'], False, [])])
id_, params = build_data(
self.apikey, 'update_pull_request',
diff --git a/rhodecode/apps/admin/views/user_groups.py b/rhodecode/apps/admin/views/user_groups.py
--- a/rhodecode/apps/admin/views/user_groups.py
+++ b/rhodecode/apps/admin/views/user_groups.py
@@ -53,7 +53,6 @@ class AdminUserGroupsView(BaseAppView, D
PermissionModel().set_global_permission_choices(
c, gettext_translator=self.request.translate)
-
return c
# permission check in data loading of
diff --git a/rhodecode/apps/repository/tests/test_repo_pullrequests.py b/rhodecode/apps/repository/tests/test_repo_pullrequests.py
--- a/rhodecode/apps/repository/tests/test_repo_pullrequests.py
+++ b/rhodecode/apps/repository/tests/test_repo_pullrequests.py
@@ -299,7 +299,7 @@ class TestPullrequestsView(object):
pull_request = pr_util.create_pull_request()
pull_request_id = pull_request.pull_request_id
PullRequestModel().update_reviewers(
- pull_request_id, [(1, ['reason'], False), (2, ['reason2'], False)],
+ pull_request_id, [(1, ['reason'], False, []), (2, ['reason2'], False, [])],
pull_request.author)
author = pull_request.user_id
repo = pull_request.target_repo.repo_id
@@ -376,6 +376,8 @@ class TestPullrequestsView(object):
('__start__', 'reasons:sequence'),
('reason', 'Some reason'),
('__end__', 'reasons:sequence'),
+ ('__start__', 'rules:sequence'),
+ ('__end__', 'rules:sequence'),
('mandatory', 'False'),
('__end__', 'reviewer:mapping'),
('__end__', 'review_members:sequence'),
@@ -433,6 +435,8 @@ class TestPullrequestsView(object):
('__start__', 'reasons:sequence'),
('reason', 'Some reason'),
('__end__', 'reasons:sequence'),
+ ('__start__', 'rules:sequence'),
+ ('__end__', 'rules:sequence'),
('mandatory', 'False'),
('__end__', 'reviewer:mapping'),
('__end__', 'review_members:sequence'),
@@ -460,7 +464,7 @@ class TestPullrequestsView(object):
# Change reviewers and check that a notification was made
PullRequestModel().update_reviewers(
- pull_request.pull_request_id, [(1, [], False)],
+ pull_request.pull_request_id, [(1, [], False, [])],
pull_request.author)
assert len(notifications.all()) == 2
@@ -497,6 +501,8 @@ class TestPullrequestsView(object):
('__start__', 'reasons:sequence'),
('reason', 'Some reason'),
('__end__', 'reasons:sequence'),
+ ('__start__', 'rules:sequence'),
+ ('__end__', 'rules:sequence'),
('mandatory', 'False'),
('__end__', 'reviewer:mapping'),
('__end__', 'review_members:sequence'),
diff --git a/rhodecode/apps/repository/utils.py b/rhodecode/apps/repository/utils.py
--- a/rhodecode/apps/repository/utils.py
+++ b/rhodecode/apps/repository/utils.py
@@ -22,7 +22,7 @@ from rhodecode.lib import helpers as h
from rhodecode.lib.utils2 import safe_int
-def reviewer_as_json(user, reasons=None, mandatory=False):
+def reviewer_as_json(user, reasons=None, mandatory=False, rules=None, user_group=None):
"""
Returns json struct of a reviewer for frontend
@@ -34,10 +34,13 @@ def reviewer_as_json(user, reasons=None,
return {
'user_id': user.user_id,
'reasons': reasons or [],
+ 'rules': rules or [],
'mandatory': mandatory,
+ 'user_group': user_group,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
+ 'user_link': h.link_to_user(user),
'gravatar_link': h.gravatar_url(user.email, 14),
}
@@ -68,7 +71,7 @@ def validate_default_reviewers(review_me
reviewer_by_id = {}
for r in review_members:
reviewer_user_id = safe_int(r['user_id'])
- entry = (reviewer_user_id, r['reasons'], r['mandatory'])
+ entry = (reviewer_user_id, r['reasons'], r['mandatory'], r['rules'])
reviewer_by_id[reviewer_user_id] = entry
reviewers.append(entry)
diff --git a/rhodecode/lib/dbmigrate/versions/084_version_4_11_0.py b/rhodecode/lib/dbmigrate/versions/084_version_4_11_0.py
new file mode 100644
--- /dev/null
+++ b/rhodecode/lib/dbmigrate/versions/084_version_4_11_0.py
@@ -0,0 +1,38 @@
+import logging
+
+from sqlalchemy import *
+
+from rhodecode.model import meta
+from rhodecode.lib.dbmigrate.versions import _reset_base, notify
+
+log = logging.getLogger(__name__)
+
+
+def upgrade(migrate_engine):
+ """
+ Upgrade operations go here.
+ Don't create your own engine; bind migrate_engine to your metadata
+ """
+ _reset_base(migrate_engine)
+ from rhodecode.lib.dbmigrate.schema import db_4_11_0_0 as db
+
+ reviewers_table = db.PullRequestReviewers.__table__
+
+ rule_data = Column(
+ 'rule_data_json',
+ db.JsonType(dialect_map=dict(mysql=UnicodeText(16384))))
+ rule_data.create(table=reviewers_table)
+
+ # issue fixups
+ fixups(db, meta.Session)
+
+
+def downgrade(migrate_engine):
+ meta = MetaData()
+ meta.bind = migrate_engine
+
+
+def fixups(models, _SESSION):
+ pass
+
+
diff --git a/rhodecode/lib/dbmigrate/versions/085_version_4_11_0.py b/rhodecode/lib/dbmigrate/versions/085_version_4_11_0.py
new file mode 100644
--- /dev/null
+++ b/rhodecode/lib/dbmigrate/versions/085_version_4_11_0.py
@@ -0,0 +1,37 @@
+import logging
+
+from sqlalchemy import *
+
+from rhodecode.model import meta
+from rhodecode.lib.dbmigrate.versions import _reset_base, notify
+
+log = logging.getLogger(__name__)
+
+
+def upgrade(migrate_engine):
+ """
+ Upgrade operations go here.
+ Don't create your own engine; bind migrate_engine to your metadata
+ """
+ _reset_base(migrate_engine)
+ from rhodecode.lib.dbmigrate.schema import db_4_11_0_0 as db
+
+ user_group_review_table = db.RepoReviewRuleUserGroup.__table__
+
+ vote_rule = Column("vote_rule", Integer(), nullable=True,
+ default=-1)
+ vote_rule.create(table=user_group_review_table)
+
+ # issue fixups
+ fixups(db, meta.Session)
+
+
+def downgrade(migrate_engine):
+ meta = MetaData()
+ meta.bind = migrate_engine
+
+
+def fixups(models, _SESSION):
+ pass
+
+
diff --git a/rhodecode/lib/helpers.py b/rhodecode/lib/helpers.py
--- a/rhodecode/lib/helpers.py
+++ b/rhodecode/lib/helpers.py
@@ -2070,3 +2070,8 @@ def go_import_header(request, db_repo=No
# we have a repo and go-get flag,
return literal(''.format(
prefix, db_repo.repo_type, clone_url))
+
+
+def reviewer_as_json(*args, **kwargs):
+ from rhodecode.apps.repository.utils import reviewer_as_json as _reviewer_as_json
+ return _reviewer_as_json(*args, **kwargs)
diff --git a/rhodecode/model/changeset_status.py b/rhodecode/model/changeset_status.py
--- a/rhodecode/model/changeset_status.py
+++ b/rhodecode/model/changeset_status.py
@@ -21,7 +21,7 @@
import itertools
import logging
-from collections import defaultdict
+import collections
from rhodecode.model import BaseModel
from rhodecode.model.db import (
@@ -68,6 +68,107 @@ class ChangesetStatusModel(BaseModel):
q = q.order_by(ChangesetStatus.version.asc())
return q
+ def calculate_group_vote(self, group_id, group_statuses_by_reviewers,
+ trim_votes=True):
+ """
+ Calculate status based on given group members, and voting rule
+
+
+ group1 - 4 members, 3 required for approval
+ user1 - approved
+ user2 - reject
+ user3 - approved
+ user4 - rejected
+
+ final_state: rejected, reasons not at least 3 votes
+
+
+ group1 - 4 members, 2 required for approval
+ user1 - approved
+ user2 - reject
+ user3 - approved
+ user4 - rejected
+
+ final_state: approved, reasons got at least 2 approvals
+
+ group1 - 4 members, ALL required for approval
+ user1 - approved
+ user2 - reject
+ user3 - approved
+ user4 - rejected
+
+ final_state: rejected, reasons not all approvals
+
+
+ group1 - 4 members, ALL required for approval
+ user1 - approved
+ user2 - approved
+ user3 - approved
+ user4 - approved
+
+ final_state: approved, reason all approvals received
+
+ group1 - 4 members, 5 required for approval
+ (approval should be shorted to number of actual members)
+
+ user1 - approved
+ user2 - approved
+ user3 - approved
+ user4 - approved
+
+ final_state: approved, reason all approvals received
+
+ """
+ group_vote_data = {}
+ got_rule = False
+ members = collections.OrderedDict()
+ for review_obj, user, reasons, mandatory, statuses \
+ in group_statuses_by_reviewers:
+
+ if not got_rule:
+ group_vote_data = review_obj.rule_user_group_data()
+ got_rule = bool(group_vote_data)
+
+ members[user.user_id] = statuses
+
+ if not group_vote_data:
+ return []
+
+ required_votes = group_vote_data['vote_rule']
+ if required_votes == -1:
+ # -1 means all required, so we replace it with how many people
+ # are in the members
+ required_votes = len(members)
+
+ if trim_votes and required_votes > len(members):
+ # we require more votes than we have members in the group
+ # in this case we trim the required votes to the number of members
+ required_votes = len(members)
+
+ approvals = sum([
+ 1 for statuses in members.values()
+ if statuses and
+ statuses[0][1].status == ChangesetStatus.STATUS_APPROVED])
+
+ calculated_votes = []
+ # we have all votes from users, now check if we have enough votes
+ # to fill other
+ fill_in = ChangesetStatus.STATUS_UNDER_REVIEW
+ if approvals >= required_votes:
+ fill_in = ChangesetStatus.STATUS_APPROVED
+
+ for member, statuses in members.items():
+ if statuses:
+ ver, latest = statuses[0]
+ if fill_in == ChangesetStatus.STATUS_APPROVED:
+ calculated_votes.append(fill_in)
+ else:
+ calculated_votes.append(latest.status)
+ else:
+ calculated_votes.append(fill_in)
+
+ return calculated_votes
+
def calculate_status(self, statuses_by_reviewers):
"""
Given the approval statuses from reviewers, calculates final approval
@@ -76,21 +177,45 @@ class ChangesetStatusModel(BaseModel):
:param statuses_by_reviewers:
"""
- votes = defaultdict(int)
+
+ def group_rule(element):
+ review_obj = element[0]
+ rule_data = review_obj.rule_user_group_data()
+ if rule_data and rule_data['id']:
+ return rule_data['id']
+
+ voting_groups = itertools.groupby(
+ sorted(statuses_by_reviewers, key=group_rule), group_rule)
+
+ voting_by_groups = [(x, list(y)) for x, y in voting_groups]
+
reviewers_number = len(statuses_by_reviewers)
- for user, reasons, mandatory, statuses in statuses_by_reviewers:
- if statuses:
- ver, latest = statuses[0]
- votes[latest.status] += 1
+ votes = collections.defaultdict(int)
+ for group, group_statuses_by_reviewers in voting_by_groups:
+ if group:
+ # calculate how the "group" voted
+ for vote_status in self.calculate_group_vote(
+ group, group_statuses_by_reviewers):
+ votes[vote_status] += 1
else:
- votes[ChangesetStatus.DEFAULT] += 1
+
+ for review_obj, user, reasons, mandatory, statuses \
+ in group_statuses_by_reviewers:
+ # individual vote
+ if statuses:
+ ver, latest = statuses[0]
+ votes[latest.status] += 1
- # all approved
- if votes.get(ChangesetStatus.STATUS_APPROVED) == reviewers_number:
+ approved_votes_count = votes[ChangesetStatus.STATUS_APPROVED]
+ rejected_votes_count = votes[ChangesetStatus.STATUS_REJECTED]
+
+ # TODO(marcink): with group voting, how does rejected work,
+ # do we ever get rejected state ?
+
+ if approved_votes_count == reviewers_number:
return ChangesetStatus.STATUS_APPROVED
- # all rejected
- if votes.get(ChangesetStatus.STATUS_REJECTED) == reviewers_number:
+ if rejected_votes_count == reviewers_number:
return ChangesetStatus.STATUS_REJECTED
return ChangesetStatus.STATUS_UNDER_REVIEW
@@ -234,7 +359,7 @@ class ChangesetStatusModel(BaseModel):
pull_request=pull_request,
with_revisions=True)
- commit_statuses = defaultdict(list)
+ commit_statuses = collections.defaultdict(list)
for st in _commit_statuses:
commit_statuses[st.author.username] += [st]
@@ -243,17 +368,18 @@ class ChangesetStatusModel(BaseModel):
def version(commit_status):
return commit_status.version
- for o in pull_request.reviewers:
- if not o.user:
+ for obj in pull_request.reviewers:
+ if not obj.user:
continue
- statuses = commit_statuses.get(o.user.username, None)
+ statuses = commit_statuses.get(obj.user.username, None)
if statuses:
- statuses = [(x, list(y)[0])
- for x, y in (itertools.groupby(
- sorted(statuses, key=version),version))]
+ status_groups = itertools.groupby(
+ sorted(statuses, key=version), version)
+ statuses = [(x, list(y)[0]) for x, y in status_groups]
pull_request_reviewers.append(
- (o.user, o.reasons, o.mandatory, statuses))
+ (obj, obj.user, obj.reasons, obj.mandatory, statuses))
+
return pull_request_reviewers
def calculated_review_status(self, pull_request, reviewers_statuses=None):
diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py
--- a/rhodecode/model/db.py
+++ b/rhodecode/model/db.py
@@ -59,8 +59,7 @@ from rhodecode.lib.utils2 import (
str2bool, safe_str, get_commit_safe, safe_unicode, md5_safe,
time_to_datetime, aslist, Optional, safe_int, get_clone_url, AttributeDict,
glob2re, StrictAttributeDict, cleaned_uri)
-from rhodecode.lib.jsonalchemy import MutationObj, MutationList, JsonType, \
- JsonRaw
+from rhodecode.lib.jsonalchemy import MutationObj, MutationList, JsonType
from rhodecode.lib.ext_json import json
from rhodecode.lib.caching_query import FromCache
from rhodecode.lib.encrypt import AESCipher
@@ -1327,7 +1326,7 @@ class UserGroup(Base, BaseModel):
@hybrid_property
def description_safe(self):
from rhodecode.lib import helpers as h
- return h.escape(self.description)
+ return h.escape(self.user_group_description)
@hybrid_property
def group_data(self):
@@ -3594,7 +3593,7 @@ class _PullRequestBase(BaseModel):
'reasons': reasons,
'review_status': st[0][1].status if st else 'not_reviewed',
}
- for reviewer, reasons, mandatory, st in
+ for obj, reviewer, reasons, mandatory, st in
pull_request.reviewers_statuses()
]
}
@@ -3790,10 +3789,34 @@ class PullRequestReviewers(Base, BaseMod
_reasons = Column(
'reason', MutationList.as_mutable(
JsonType('list', dialect_map=dict(mysql=UnicodeText(16384)))))
+
mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
user = relationship('User')
pull_request = relationship('PullRequest')
+ rule_data = Column(
+ 'rule_data_json',
+ JsonType(dialect_map=dict(mysql=UnicodeText(16384))))
+
+ def rule_user_group_data(self):
+ """
+ Returns the voting user group rule data for this reviewer
+ """
+
+ if self.rule_data and 'vote_rule' in self.rule_data:
+ user_group_data = {}
+ if 'rule_user_group_entry_id' in self.rule_data:
+ # means a group with voting rules !
+ user_group_data['id'] = self.rule_data['rule_user_group_entry_id']
+ user_group_data['name'] = self.rule_data['rule_name']
+ user_group_data['vote_rule'] = self.rule_data['vote_rule']
+
+ return user_group_data
+
+ def __unicode__(self):
+ return u"<%s('id:%s')>" % (self.__class__.__name__,
+ self.pull_requests_reviewers_id)
+
class Notification(Base, BaseModel):
__tablename__ = 'notifications'
@@ -4086,6 +4109,7 @@ class RepoReviewRuleUser(Base, BaseModel
{'extend_existing': True, 'mysql_engine': 'InnoDB',
'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
)
+
repo_review_rule_user_id = Column('repo_review_rule_user_id', Integer(), primary_key=True)
repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False)
@@ -4104,17 +4128,28 @@ class RepoReviewRuleUserGroup(Base, Base
{'extend_existing': True, 'mysql_engine': 'InnoDB',
'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
)
+ VOTE_RULE_ALL = -1
+
repo_review_rule_users_group_id = Column('repo_review_rule_users_group_id', Integer(), primary_key=True)
repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
users_group_id = Column("users_group_id", Integer(),ForeignKey('users_groups.users_group_id'), nullable=False)
mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
+ vote_rule = Column("vote_rule", Integer(), nullable=True, default=VOTE_RULE_ALL)
users_group = relationship('UserGroup')
def rule_data(self):
return {
- 'mandatory': self.mandatory
+ 'mandatory': self.mandatory,
+ 'vote_rule': self.vote_rule
}
+ @property
+ def vote_rule_label(self):
+ if not self.vote_rule or self.vote_rule == self.VOTE_RULE_ALL:
+ return 'all must vote'
+ else:
+ return 'min. vote {}'.format(self.vote_rule)
+
class RepoReviewRule(Base, BaseModel):
__tablename__ = 'repo_review_rules'
@@ -4225,12 +4260,20 @@ class RepoReviewRule(Base, BaseModel):
for rule_user_group in self.rule_user_groups:
source_data = {
+ 'user_group_id': rule_user_group.users_group.users_group_id,
'name': rule_user_group.users_group.users_group_name,
'members': len(rule_user_group.users_group.members)
}
for member in rule_user_group.users_group.members:
if member.user.active:
- users[member.user.username] = {
+ key = member.user.username
+ if key in users:
+ # skip this member as we have him already
+ # this prevents from override the "first" matched
+ # users with duplicates in multiple groups
+ continue
+
+ users[key] = {
'user': member.user,
'source': 'user_group',
'source_data': source_data,
@@ -4239,6 +4282,13 @@ class RepoReviewRule(Base, BaseModel):
return users
+ def user_group_vote_rule(self):
+ rules = []
+ if self.rule_user_groups:
+ for user_group in self.rule_user_groups:
+ rules.append(user_group)
+ return rules
+
def __repr__(self):
return '' % (
self.repo_review_rule_id, self.repo)
diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py
--- a/rhodecode/model/forms.py
+++ b/rhodecode/model/forms.py
@@ -584,6 +584,7 @@ def PullRequestForm(localizer, repo_id):
class ReviewerForm(formencode.Schema):
user_id = v.Int(not_empty=True)
reasons = All()
+ rules = All(v.UniqueList(localizer, convert=int)())
mandatory = v.StringBoolean()
class _PullRequestForm(formencode.Schema):
diff --git a/rhodecode/model/pull_request.py b/rhodecode/model/pull_request.py
--- a/rhodecode/model/pull_request.py
+++ b/rhodecode/model/pull_request.py
@@ -51,7 +51,7 @@ from rhodecode.model.changeset_status im
from rhodecode.model.comment import CommentsModel
from rhodecode.model.db import (
or_, PullRequest, PullRequestReviewers, ChangesetStatus,
- PullRequestVersion, ChangesetComment, Repository)
+ PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
from rhodecode.model.meta import Session
from rhodecode.model.notification import NotificationModel, \
EmailNotificationModel
@@ -468,7 +468,7 @@ class PullRequestModel(BaseModel):
reviewer_ids = set()
# members / reviewers
for reviewer_object in reviewers:
- user_id, reasons, mandatory = reviewer_object
+ user_id, reasons, mandatory, rules = reviewer_object
user = self._get_user(user_id)
# skip duplicates
@@ -482,6 +482,33 @@ class PullRequestModel(BaseModel):
reviewer.pull_request = pull_request
reviewer.reasons = reasons
reviewer.mandatory = mandatory
+
+ # NOTE(marcink): pick only first rule for now
+ rule_id = rules[0] if rules else None
+ rule = RepoReviewRule.get(rule_id) if rule_id else None
+ if rule:
+ review_group = rule.user_group_vote_rule()
+ if review_group:
+ # NOTE(marcink):
+ # again, can be that user is member of more,
+ # but we pick the first same, as default reviewers algo
+ review_group = review_group[0]
+
+ rule_data = {
+ 'rule_name':
+ rule.review_rule_name,
+ 'rule_user_group_entry_id':
+ review_group.repo_review_rule_users_group_id,
+ 'rule_user_group_name':
+ review_group.users_group.users_group_name,
+ 'rule_user_group_members':
+ [x.user.username for x in review_group.users_group.members],
+ }
+ # e.g {'vote_rule': -1, 'mandatory': True}
+ rule_data.update(review_group.rule_data())
+
+ reviewer.rule_data = rule_data
+
Session().add(reviewer)
# Set approval status to "Under Review" for all commits which are
@@ -962,14 +989,14 @@ class PullRequestModel(BaseModel):
:param pull_request: the pr to update
:param reviewer_data: list of tuples
- [(user, ['reason1', 'reason2'], mandatory_flag)]
+ [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
"""
pull_request = self.__get_pull_request(pull_request)
if pull_request.is_closed():
raise ValueError('This pull request is closed')
reviewers = {}
- for user_id, reasons, mandatory in reviewer_data:
+ for user_id, reasons, mandatory, rules in reviewer_data:
if isinstance(user_id, (int, basestring)):
user_id = self._get_user(user_id).user_id
reviewers[user_id] = {
diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py
--- a/rhodecode/model/user.py
+++ b/rhodecode/model/user.py
@@ -74,6 +74,7 @@ class UserModel(BaseModel):
'username': user.username,
'email': user.email,
'icon_link': h.gravatar_url(user.email, 30),
+ 'profile_link': h.link_to_user(user),
'value_display': h.escape(h.person(user)),
'value': user.username,
'value_type': 'user',
diff --git a/rhodecode/model/validation_schema/schemas/reviewer_schema.py b/rhodecode/model/validation_schema/schemas/reviewer_schema.py
--- a/rhodecode/model/validation_schema/schemas/reviewer_schema.py
+++ b/rhodecode/model/validation_schema/schemas/reviewer_schema.py
@@ -26,6 +26,7 @@ class ReviewerSchema(colander.MappingSch
username = colander.SchemaNode(types.StrOrIntType())
reasons = colander.SchemaNode(colander.List(), missing=['no reason specified'])
mandatory = colander.SchemaNode(colander.Boolean(), missing=False)
+ rules = colander.SchemaNode(colander.List(), missing=[])
class ReviewerListSchema(colander.SequenceSchema):
diff --git a/rhodecode/public/css/main.less b/rhodecode/public/css/main.less
--- a/rhodecode/public/css/main.less
+++ b/rhodecode/public/css/main.less
@@ -1324,7 +1324,7 @@ table.integrations {
.reviewers ul li {
position: relative;
width: 100%;
- margin-bottom: 8px;
+ padding-bottom: 8px;
}
.reviewer_entry {
@@ -1335,19 +1335,15 @@ table.integrations {
width: 100%;
overflow: auto;
}
-
-.reviewer_reason_container {
- padding-left: 20px;
+.reviewer_reason {
+ padding-left: 20px;
+ line-height: 1.5em;
}
-
-.reviewer_reason {
-}
-
.reviewer_status {
display: inline-block;
vertical-align: top;
- width: 7%;
- min-width: 20px;
+ width: 25px;
+ min-width: 25px;
height: 1.2em;
margin-top: 3px;
line-height: 1em;
@@ -1370,7 +1366,17 @@ table.integrations {
}
}
-.reviewer_member_mandatory,
+.reviewer_member_mandatory {
+ position: absolute;
+ left: 15px;
+ top: 8px;
+ width: 16px;
+ font-size: 11px;
+ margin: 0;
+ padding: 0;
+ color: black;
+}
+
.reviewer_member_mandatory_remove,
.reviewer_member_remove {
position: absolute;
@@ -1386,10 +1392,6 @@ table.integrations {
color: @grey4;
}
-.reviewer_member_mandatory {
- padding-top:20px;
-}
-
.reviewer_member_status {
margin-top: 5px;
}
@@ -1849,13 +1851,6 @@ BIN_FILENODE = 7
}
}
-.no-object-border {
- text-align: center;
- padding: 20px;
- border-radius: @border-radius-base;
- border: 1px solid @grey4;
- color: @grey4;
-}
.creation_in_progress {
color: @grey4
diff --git a/rhodecode/public/js/src/ejs.js b/rhodecode/public/js/src/ejs.js
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/js/src/ejs.js
@@ -0,0 +1,1494 @@
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.ejs = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o
+ * @author Tiancheng "Timothy" Gu
+ * @project EJS
+ * @license {@link http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0}
+ */
+
+/**
+ * EJS internal functions.
+ *
+ * Technically this "module" lies in the same file as {@link module:ejs}, for
+ * the sake of organization all the private functions re grouped into this
+ * module.
+ *
+ * @module ejs-internal
+ * @private
+ */
+
+/**
+ * Embedded JavaScript templating engine.
+ *
+ * @module ejs
+ * @public
+ */
+
+var fs = require('fs');
+var path = require('path');
+var utils = require('./utils');
+
+var scopeOptionWarned = false;
+var _VERSION_STRING = require('../package.json').version;
+var _DEFAULT_DELIMITER = '%';
+var _DEFAULT_LOCALS_NAME = 'locals';
+var _NAME = 'ejs';
+var _REGEX_STRING = '(<%%|%%>|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)';
+var _OPTS = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
+ 'client', '_with', 'rmWhitespace', 'strict', 'filename'];
+// We don't allow 'cache' option to be passed in the data obj
+// for the normal `render` call, but this is where Express puts it
+// so we make an exception for `renderFile`
+var _OPTS_EXPRESS = _OPTS.concat('cache');
+var _BOM = /^\uFEFF/;
+
+/**
+ * EJS template function cache. This can be a LRU object from lru-cache NPM
+ * module. By default, it is {@link module:utils.cache}, a simple in-process
+ * cache that grows continuously.
+ *
+ * @type {Cache}
+ */
+
+exports.cache = utils.cache;
+
+/**
+ * Custom file loader. Useful for template preprocessing or restricting access
+ * to a certain part of the filesystem.
+ *
+ * @type {fileLoader}
+ */
+
+exports.fileLoader = fs.readFileSync;
+
+/**
+ * Name of the object containing the locals.
+ *
+ * This variable is overridden by {@link Options}`.localsName` if it is not
+ * `undefined`.
+ *
+ * @type {String}
+ * @public
+ */
+
+exports.localsName = _DEFAULT_LOCALS_NAME;
+
+/**
+ * Get the path to the included file from the parent file path and the
+ * specified path.
+ *
+ * @param {String} name specified path
+ * @param {String} filename parent file path
+ * @param {Boolean} isDir parent file path whether is directory
+ * @return {String}
+ */
+exports.resolveInclude = function(name, filename, isDir) {
+ var dirname = path.dirname;
+ var extname = path.extname;
+ var resolve = path.resolve;
+ var includePath = resolve(isDir ? filename : dirname(filename), name);
+ var ext = extname(name);
+ if (!ext) {
+ includePath += '.ejs';
+ }
+ return includePath;
+};
+
+/**
+ * Get the path to the included file by Options
+ *
+ * @param {String} path specified path
+ * @param {Options} options compilation options
+ * @return {String}
+ */
+function getIncludePath(path, options) {
+ var includePath;
+ var filePath;
+ var views = options.views;
+
+ // Abs path
+ if (path.charAt(0) == '/') {
+ includePath = exports.resolveInclude(path.replace(/^\/*/,''), options.root || '/', true);
+ }
+ // Relative paths
+ else {
+ // Look relative to a passed filename first
+ if (options.filename) {
+ filePath = exports.resolveInclude(path, options.filename);
+ if (fs.existsSync(filePath)) {
+ includePath = filePath;
+ }
+ }
+ // Then look in any views directories
+ if (!includePath) {
+ if (Array.isArray(views) && views.some(function (v) {
+ filePath = exports.resolveInclude(path, v, true);
+ return fs.existsSync(filePath);
+ })) {
+ includePath = filePath;
+ }
+ }
+ if (!includePath) {
+ throw new Error('Could not find include include file.');
+ }
+ }
+ return includePath;
+}
+
+/**
+ * Get the template from a string or a file, either compiled on-the-fly or
+ * read from cache (if enabled), and cache the template if needed.
+ *
+ * If `template` is not set, the file specified in `options.filename` will be
+ * read.
+ *
+ * If `options.cache` is true, this function reads the file from
+ * `options.filename` so it must be set prior to calling this function.
+ *
+ * @memberof module:ejs-internal
+ * @param {Options} options compilation options
+ * @param {String} [template] template source
+ * @return {(TemplateFunction|ClientFunction)}
+ * Depending on the value of `options.client`, either type might be returned.
+ * @static
+ */
+
+function handleCache(options, template) {
+ var func;
+ var filename = options.filename;
+ var hasTemplate = arguments.length > 1;
+
+ if (options.cache) {
+ if (!filename) {
+ throw new Error('cache option requires a filename');
+ }
+ func = exports.cache.get(filename);
+ if (func) {
+ return func;
+ }
+ if (!hasTemplate) {
+ template = fileLoader(filename).toString().replace(_BOM, '');
+ }
+ }
+ else if (!hasTemplate) {
+ // istanbul ignore if: should not happen at all
+ if (!filename) {
+ throw new Error('Internal EJS error: no file name or template '
+ + 'provided');
+ }
+ template = fileLoader(filename).toString().replace(_BOM, '');
+ }
+ func = exports.compile(template, options);
+ if (options.cache) {
+ exports.cache.set(filename, func);
+ }
+ return func;
+}
+
+/**
+ * Try calling handleCache with the given options and data and call the
+ * callback with the result. If an error occurs, call the callback with
+ * the error. Used by renderFile().
+ *
+ * @memberof module:ejs-internal
+ * @param {Options} options compilation options
+ * @param {Object} data template data
+ * @param {RenderFileCallback} cb callback
+ * @static
+ */
+
+function tryHandleCache(options, data, cb) {
+ var result;
+ try {
+ result = handleCache(options)(data);
+ }
+ catch (err) {
+ return cb(err);
+ }
+ return cb(null, result);
+}
+
+/**
+ * fileLoader is independent
+ *
+ * @param {String} filePath ejs file path.
+ * @return {String} The contents of the specified file.
+ * @static
+ */
+
+function fileLoader(filePath){
+ return exports.fileLoader(filePath);
+}
+
+/**
+ * Get the template function.
+ *
+ * If `options.cache` is `true`, then the template is cached.
+ *
+ * @memberof module:ejs-internal
+ * @param {String} path path for the specified file
+ * @param {Options} options compilation options
+ * @return {(TemplateFunction|ClientFunction)}
+ * Depending on the value of `options.client`, either type might be returned
+ * @static
+ */
+
+function includeFile(path, options) {
+ var opts = utils.shallowCopy({}, options);
+ opts.filename = getIncludePath(path, opts);
+ return handleCache(opts);
+}
+
+/**
+ * Get the JavaScript source of an included file.
+ *
+ * @memberof module:ejs-internal
+ * @param {String} path path for the specified file
+ * @param {Options} options compilation options
+ * @return {Object}
+ * @static
+ */
+
+function includeSource(path, options) {
+ var opts = utils.shallowCopy({}, options);
+ var includePath;
+ var template;
+ includePath = getIncludePath(path, opts);
+ template = fileLoader(includePath).toString().replace(_BOM, '');
+ opts.filename = includePath;
+ var templ = new Template(template, opts);
+ templ.generateSource();
+ return {
+ source: templ.source,
+ filename: includePath,
+ template: template
+ };
+}
+
+/**
+ * Re-throw the given `err` in context to the `str` of ejs, `filename`, and
+ * `lineno`.
+ *
+ * @implements RethrowCallback
+ * @memberof module:ejs-internal
+ * @param {Error} err Error object
+ * @param {String} str EJS source
+ * @param {String} filename file name of the EJS file
+ * @param {String} lineno line number of the error
+ * @static
+ */
+
+function rethrow(err, str, flnm, lineno, esc){
+ var lines = str.split('\n');
+ var start = Math.max(lineno - 3, 0);
+ var end = Math.min(lines.length, lineno + 3);
+ var filename = esc(flnm); // eslint-disable-line
+ // Error context
+ var context = lines.slice(start, end).map(function (line, i){
+ var curr = i + start + 1;
+ return (curr == lineno ? ' >> ' : ' ')
+ + curr
+ + '| '
+ + line;
+ }).join('\n');
+
+ // Alter exception message
+ err.path = filename;
+ err.message = (filename || 'ejs') + ':'
+ + lineno + '\n'
+ + context + '\n\n'
+ + err.message;
+
+ throw err;
+}
+
+function stripSemi(str){
+ return str.replace(/;(\s*$)/, '$1');
+}
+
+/**
+ * Compile the given `str` of ejs into a template function.
+ *
+ * @param {String} template EJS template
+ *
+ * @param {Options} opts compilation options
+ *
+ * @return {(TemplateFunction|ClientFunction)}
+ * Depending on the value of `opts.client`, either type might be returned.
+ * @public
+ */
+
+exports.compile = function compile(template, opts) {
+ var templ;
+
+ // v1 compat
+ // 'scope' is 'context'
+ // FIXME: Remove this in a future version
+ if (opts && opts.scope) {
+ if (!scopeOptionWarned){
+ console.warn('`scope` option is deprecated and will be removed in EJS 3');
+ scopeOptionWarned = true;
+ }
+ if (!opts.context) {
+ opts.context = opts.scope;
+ }
+ delete opts.scope;
+ }
+ templ = new Template(template, opts);
+ return templ.compile();
+};
+
+/**
+ * Render the given `template` of ejs.
+ *
+ * If you would like to include options but not data, you need to explicitly
+ * call this function with `data` being an empty object or `null`.
+ *
+ * @param {String} template EJS template
+ * @param {Object} [data={}] template data
+ * @param {Options} [opts={}] compilation and rendering options
+ * @return {String}
+ * @public
+ */
+
+exports.render = function (template, d, o) {
+ var data = d || {};
+ var opts = o || {};
+
+ // No options object -- if there are optiony names
+ // in the data, copy them to options
+ if (arguments.length == 2) {
+ utils.shallowCopyFromList(opts, data, _OPTS);
+ }
+
+ return handleCache(opts, template)(data);
+};
+
+/**
+ * Render an EJS file at the given `path` and callback `cb(err, str)`.
+ *
+ * If you would like to include options but not data, you need to explicitly
+ * call this function with `data` being an empty object or `null`.
+ *
+ * @param {String} path path to the EJS file
+ * @param {Object} [data={}] template data
+ * @param {Options} [opts={}] compilation and rendering options
+ * @param {RenderFileCallback} cb callback
+ * @public
+ */
+
+exports.renderFile = function () {
+ var filename = arguments[0];
+ var cb = arguments[arguments.length - 1];
+ var opts = {filename: filename};
+ var data;
+
+ if (arguments.length > 2) {
+ data = arguments[1];
+
+ // No options object -- if there are optiony names
+ // in the data, copy them to options
+ if (arguments.length === 3) {
+ // Express 4
+ if (data.settings) {
+ if (data.settings['view options']) {
+ utils.shallowCopyFromList(opts, data.settings['view options'], _OPTS_EXPRESS);
+ }
+ if (data.settings.views) {
+ opts.views = data.settings.views;
+ }
+ }
+ // Express 3 and lower
+ else {
+ utils.shallowCopyFromList(opts, data, _OPTS_EXPRESS);
+ }
+ }
+ else {
+ // Use shallowCopy so we don't pollute passed in opts obj with new vals
+ utils.shallowCopy(opts, arguments[2]);
+ }
+
+ opts.filename = filename;
+ }
+ else {
+ data = {};
+ }
+
+ return tryHandleCache(opts, data, cb);
+};
+
+/**
+ * Clear intermediate JavaScript cache. Calls {@link Cache#reset}.
+ * @public
+ */
+
+exports.clearCache = function () {
+ exports.cache.reset();
+};
+
+function Template(text, opts) {
+ opts = opts || {};
+ var options = {};
+ this.templateText = text;
+ this.mode = null;
+ this.truncate = false;
+ this.currentLine = 1;
+ this.source = '';
+ this.dependencies = [];
+ options.client = opts.client || false;
+ options.escapeFunction = opts.escape || utils.escapeXML;
+ options.compileDebug = opts.compileDebug !== false;
+ options.debug = !!opts.debug;
+ options.filename = opts.filename;
+ options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
+ options.strict = opts.strict || false;
+ options.context = opts.context;
+ options.cache = opts.cache || false;
+ options.rmWhitespace = opts.rmWhitespace;
+ options.root = opts.root;
+ options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
+ options.views = opts.views;
+
+ if (options.strict) {
+ options._with = false;
+ }
+ else {
+ options._with = typeof opts._with != 'undefined' ? opts._with : true;
+ }
+
+ this.opts = options;
+
+ this.regex = this.createRegex();
+}
+
+Template.modes = {
+ EVAL: 'eval',
+ ESCAPED: 'escaped',
+ RAW: 'raw',
+ COMMENT: 'comment',
+ LITERAL: 'literal'
+};
+
+Template.prototype = {
+ createRegex: function () {
+ var str = _REGEX_STRING;
+ var delim = utils.escapeRegExpChars(this.opts.delimiter);
+ str = str.replace(/%/g, delim);
+ return new RegExp(str);
+ },
+
+ compile: function () {
+ var src;
+ var fn;
+ var opts = this.opts;
+ var prepended = '';
+ var appended = '';
+ var escapeFn = opts.escapeFunction;
+
+ if (!this.source) {
+ this.generateSource();
+ prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
+ if (opts._with !== false) {
+ prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
+ appended += ' }' + '\n';
+ }
+ appended += ' return __output.join("");' + '\n';
+ this.source = prepended + this.source + appended;
+ }
+
+ if (opts.compileDebug) {
+ src = 'var __line = 1' + '\n'
+ + ' , __lines = ' + JSON.stringify(this.templateText) + '\n'
+ + ' , __filename = ' + (opts.filename ?
+ JSON.stringify(opts.filename) : 'undefined') + ';' + '\n'
+ + 'try {' + '\n'
+ + this.source
+ + '} catch (e) {' + '\n'
+ + ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
+ + '}' + '\n';
+ }
+ else {
+ src = this.source;
+ }
+
+ if (opts.client) {
+ src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
+ if (opts.compileDebug) {
+ src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
+ }
+ }
+
+ if (opts.strict) {
+ src = '"use strict";\n' + src;
+ }
+ if (opts.debug) {
+ console.log(src);
+ }
+
+ try {
+ fn = new Function(opts.localsName + ', escapeFn, include, rethrow', src);
+ }
+ catch(e) {
+ // istanbul ignore else
+ if (e instanceof SyntaxError) {
+ if (opts.filename) {
+ e.message += ' in ' + opts.filename;
+ }
+ e.message += ' while compiling ejs\n\n';
+ e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n';
+ e.message += 'https://github.com/RyanZim/EJS-Lint';
+ }
+ throw e;
+ }
+
+ if (opts.client) {
+ fn.dependencies = this.dependencies;
+ return fn;
+ }
+
+ // Return a callable function which will execute the function
+ // created by the source-code, with the passed data as locals
+ // Adds a local `include` function which allows full recursive include
+ var returnedFn = function (data) {
+ var include = function (path, includeData) {
+ var d = utils.shallowCopy({}, data);
+ if (includeData) {
+ d = utils.shallowCopy(d, includeData);
+ }
+ return includeFile(path, opts)(d);
+ };
+ return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
+ };
+ returnedFn.dependencies = this.dependencies;
+ return returnedFn;
+ },
+
+ generateSource: function () {
+ var opts = this.opts;
+
+ if (opts.rmWhitespace) {
+ // Have to use two separate replace here as `^` and `$` operators don't
+ // work well with `\r`.
+ this.templateText =
+ this.templateText.replace(/\r/g, '').replace(/^\s+|\s+$/gm, '');
+ }
+
+ // Slurp spaces and tabs before <%_ and after _%>
+ this.templateText =
+ this.templateText.replace(/[ \t]*<%_/gm, '<%_').replace(/_%>[ \t]*/gm, '_%>');
+
+ var self = this;
+ var matches = this.parseTemplateText();
+ var d = this.opts.delimiter;
+
+ if (matches && matches.length) {
+ matches.forEach(function (line, index) {
+ var opening;
+ var closing;
+ var include;
+ var includeOpts;
+ var includeObj;
+ var includeSrc;
+ // If this is an opening tag, check for closing tags
+ // FIXME: May end up with some false positives here
+ // Better to store modes as k/v with '<' + delimiter as key
+ // Then this can simply check against the map
+ if ( line.indexOf('<' + d) === 0 // If it is a tag
+ && line.indexOf('<' + d + d) !== 0) { // and is not escaped
+ closing = matches[index + 2];
+ if (!(closing == d + '>' || closing == '-' + d + '>' || closing == '_' + d + '>')) {
+ throw new Error('Could not find matching close tag for "' + line + '".');
+ }
+ }
+ // HACK: backward-compat `include` preprocessor directives
+ if ((include = line.match(/^\s*include\s+(\S+)/))) {
+ opening = matches[index - 1];
+ // Must be in EVAL or RAW mode
+ if (opening && (opening == '<' + d || opening == '<' + d + '-' || opening == '<' + d + '_')) {
+ includeOpts = utils.shallowCopy({}, self.opts);
+ includeObj = includeSource(include[1], includeOpts);
+ if (self.opts.compileDebug) {
+ includeSrc =
+ ' ; (function(){' + '\n'
+ + ' var __line = 1' + '\n'
+ + ' , __lines = ' + JSON.stringify(includeObj.template) + '\n'
+ + ' , __filename = ' + JSON.stringify(includeObj.filename) + ';' + '\n'
+ + ' try {' + '\n'
+ + includeObj.source
+ + ' } catch (e) {' + '\n'
+ + ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
+ + ' }' + '\n'
+ + ' ; }).call(this)' + '\n';
+ }else{
+ includeSrc = ' ; (function(){' + '\n' + includeObj.source +
+ ' ; }).call(this)' + '\n';
+ }
+ self.source += includeSrc;
+ self.dependencies.push(exports.resolveInclude(include[1],
+ includeOpts.filename));
+ return;
+ }
+ }
+ self.scanLine(line);
+ });
+ }
+
+ },
+
+ parseTemplateText: function () {
+ var str = this.templateText;
+ var pat = this.regex;
+ var result = pat.exec(str);
+ var arr = [];
+ var firstPos;
+
+ while (result) {
+ firstPos = result.index;
+
+ if (firstPos !== 0) {
+ arr.push(str.substring(0, firstPos));
+ str = str.slice(firstPos);
+ }
+
+ arr.push(result[0]);
+ str = str.slice(result[0].length);
+ result = pat.exec(str);
+ }
+
+ if (str) {
+ arr.push(str);
+ }
+
+ return arr;
+ },
+
+ _addOutput: function (line) {
+ if (this.truncate) {
+ // Only replace single leading linebreak in the line after
+ // -%> tag -- this is the single, trailing linebreak
+ // after the tag that the truncation mode replaces
+ // Handle Win / Unix / old Mac linebreaks -- do the \r\n
+ // combo first in the regex-or
+ line = line.replace(/^(?:\r\n|\r|\n)/, '');
+ this.truncate = false;
+ }
+ else if (this.opts.rmWhitespace) {
+ // rmWhitespace has already removed trailing spaces, just need
+ // to remove linebreaks
+ line = line.replace(/^\n/, '');
+ }
+ if (!line) {
+ return line;
+ }
+
+ // Preserve literal slashes
+ line = line.replace(/\\/g, '\\\\');
+
+ // Convert linebreaks
+ line = line.replace(/\n/g, '\\n');
+ line = line.replace(/\r/g, '\\r');
+
+ // Escape double-quotes
+ // - this will be the delimiter during execution
+ line = line.replace(/"/g, '\\"');
+ this.source += ' ; __append("' + line + '")' + '\n';
+ },
+
+ scanLine: function (line) {
+ var self = this;
+ var d = this.opts.delimiter;
+ var newLineCount = 0;
+
+ newLineCount = (line.split('\n').length - 1);
+
+ switch (line) {
+ case '<' + d:
+ case '<' + d + '_':
+ this.mode = Template.modes.EVAL;
+ break;
+ case '<' + d + '=':
+ this.mode = Template.modes.ESCAPED;
+ break;
+ case '<' + d + '-':
+ this.mode = Template.modes.RAW;
+ break;
+ case '<' + d + '#':
+ this.mode = Template.modes.COMMENT;
+ break;
+ case '<' + d + d:
+ this.mode = Template.modes.LITERAL;
+ this.source += ' ; __append("' + line.replace('<' + d + d, '<' + d) + '")' + '\n';
+ break;
+ case d + d + '>':
+ this.mode = Template.modes.LITERAL;
+ this.source += ' ; __append("' + line.replace(d + d + '>', d + '>') + '")' + '\n';
+ break;
+ case d + '>':
+ case '-' + d + '>':
+ case '_' + d + '>':
+ if (this.mode == Template.modes.LITERAL) {
+ this._addOutput(line);
+ }
+
+ this.mode = null;
+ this.truncate = line.indexOf('-') === 0 || line.indexOf('_') === 0;
+ break;
+ default:
+ // In script mode, depends on type of tag
+ if (this.mode) {
+ // If '//' is found without a line break, add a line break.
+ switch (this.mode) {
+ case Template.modes.EVAL:
+ case Template.modes.ESCAPED:
+ case Template.modes.RAW:
+ if (line.lastIndexOf('//') > line.lastIndexOf('\n')) {
+ line += '\n';
+ }
+ }
+ switch (this.mode) {
+ // Just executing code
+ case Template.modes.EVAL:
+ this.source += ' ; ' + line + '\n';
+ break;
+ // Exec, esc, and output
+ case Template.modes.ESCAPED:
+ this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n';
+ break;
+ // Exec and output
+ case Template.modes.RAW:
+ this.source += ' ; __append(' + stripSemi(line) + ')' + '\n';
+ break;
+ case Template.modes.COMMENT:
+ // Do nothing
+ break;
+ // Literal <%% mode, append as raw output
+ case Template.modes.LITERAL:
+ this._addOutput(line);
+ break;
+ }
+ }
+ // In string mode, just add the output
+ else {
+ this._addOutput(line);
+ }
+ }
+
+ if (self.opts.compileDebug && newLineCount) {
+ this.currentLine += newLineCount;
+ this.source += ' ; __line = ' + this.currentLine + '\n';
+ }
+ }
+};
+
+/**
+ * Escape characters reserved in XML.
+ *
+ * This is simply an export of {@link module:utils.escapeXML}.
+ *
+ * If `markup` is `undefined` or `null`, the empty string is returned.
+ *
+ * @param {String} markup Input string
+ * @return {String} Escaped string
+ * @public
+ * @func
+ * */
+exports.escapeXML = utils.escapeXML;
+
+/**
+ * Express.js support.
+ *
+ * This is an alias for {@link module:ejs.renderFile}, in order to support
+ * Express.js out-of-the-box.
+ *
+ * @func
+ */
+
+exports.__express = exports.renderFile;
+
+// Add require support
+/* istanbul ignore else */
+if (require.extensions) {
+ require.extensions['.ejs'] = function (module, flnm) {
+ var filename = flnm || /* istanbul ignore next */ module.filename;
+ var options = {
+ filename: filename,
+ client: true
+ };
+ var template = fileLoader(filename).toString();
+ var fn = exports.compile(template, options);
+ module._compile('module.exports = ' + fn.toString() + ';', filename);
+ };
+}
+
+/**
+ * Version of EJS.
+ *
+ * @readonly
+ * @type {String}
+ * @public
+ */
+
+exports.VERSION = _VERSION_STRING;
+
+/**
+ * Name for detection of EJS.
+ *
+ * @readonly
+ * @type {String}
+ * @public
+ */
+
+exports.name = _NAME;
+
+/* istanbul ignore if */
+if (typeof window != 'undefined') {
+ window.ejs = exports;
+}
+
+},{"../package.json":6,"./utils":2,"fs":3,"path":4}],2:[function(require,module,exports){
+/*
+ * EJS Embedded JavaScript templates
+ * Copyright 2112 Matthew Eernisse (mde@fleegix.org)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+*/
+
+/**
+ * Private utility functions
+ * @module utils
+ * @private
+ */
+
+'use strict';
+
+var regExpChars = /[|\\{}()[\]^$+*?.]/g;
+
+/**
+ * Escape characters reserved in regular expressions.
+ *
+ * If `string` is `undefined` or `null`, the empty string is returned.
+ *
+ * @param {String} string Input string
+ * @return {String} Escaped string
+ * @static
+ * @private
+ */
+exports.escapeRegExpChars = function (string) {
+ // istanbul ignore if
+ if (!string) {
+ return '';
+ }
+ return String(string).replace(regExpChars, '\\$&');
+};
+
+var _ENCODE_HTML_RULES = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+};
+var _MATCH_HTML = /[&<>\'"]/g;
+
+function encode_char(c) {
+ return _ENCODE_HTML_RULES[c] || c;
+}
+
+/**
+ * Stringified version of constants used by {@link module:utils.escapeXML}.
+ *
+ * It is used in the process of generating {@link ClientFunction}s.
+ *
+ * @readonly
+ * @type {String}
+ */
+
+var escapeFuncStr =
+ 'var _ENCODE_HTML_RULES = {\n'
++ ' "&": "&"\n'
++ ' , "<": "<"\n'
++ ' , ">": ">"\n'
++ ' , \'"\': """\n'
++ ' , "\'": "'"\n'
++ ' }\n'
++ ' , _MATCH_HTML = /[&<>\'"]/g;\n'
++ 'function encode_char(c) {\n'
++ ' return _ENCODE_HTML_RULES[c] || c;\n'
++ '};\n';
+
+/**
+ * Escape characters reserved in XML.
+ *
+ * If `markup` is `undefined` or `null`, the empty string is returned.
+ *
+ * @implements {EscapeCallback}
+ * @param {String} markup Input string
+ * @return {String} Escaped string
+ * @static
+ * @private
+ */
+
+exports.escapeXML = function (markup) {
+ return markup == undefined
+ ? ''
+ : String(markup)
+ .replace(_MATCH_HTML, encode_char);
+};
+exports.escapeXML.toString = function () {
+ return Function.prototype.toString.call(this) + ';\n' + escapeFuncStr;
+};
+
+/**
+ * Naive copy of properties from one object to another.
+ * Does not recurse into non-scalar properties
+ * Does not check to see if the property has a value before copying
+ *
+ * @param {Object} to Destination object
+ * @param {Object} from Source object
+ * @return {Object} Destination object
+ * @static
+ * @private
+ */
+exports.shallowCopy = function (to, from) {
+ from = from || {};
+ for (var p in from) {
+ to[p] = from[p];
+ }
+ return to;
+};
+
+/**
+ * Naive copy of a list of key names, from one object to another.
+ * Only copies property if it is actually defined
+ * Does not recurse into non-scalar properties
+ *
+ * @param {Object} to Destination object
+ * @param {Object} from Source object
+ * @param {Array} list List of properties to copy
+ * @return {Object} Destination object
+ * @static
+ * @private
+ */
+exports.shallowCopyFromList = function (to, from, list) {
+ for (var i = 0; i < list.length; i++) {
+ var p = list[i];
+ if (typeof from[p] != 'undefined') {
+ to[p] = from[p];
+ }
+ }
+ return to;
+};
+
+/**
+ * Simple in-process cache implementation. Does not implement limits of any
+ * sort.
+ *
+ * @implements Cache
+ * @static
+ * @private
+ */
+exports.cache = {
+ _data: {},
+ set: function (key, val) {
+ this._data[key] = val;
+ },
+ get: function (key) {
+ return this._data[key];
+ },
+ reset: function () {
+ this._data = {};
+ }
+};
+
+},{}],3:[function(require,module,exports){
+
+},{}],4:[function(require,module,exports){
+(function (process){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+// resolves . and .. elements in a path array with directory names there
+// must be no slashes, empty elements, or device names (c:\) in the array
+// (so also no leading and trailing slashes - it does not distinguish
+// relative and absolute paths)
+function normalizeArray(parts, allowAboveRoot) {
+ // if the path tries to go above the root, `up` ends up > 0
+ var up = 0;
+ for (var i = parts.length - 1; i >= 0; i--) {
+ var last = parts[i];
+ if (last === '.') {
+ parts.splice(i, 1);
+ } else if (last === '..') {
+ parts.splice(i, 1);
+ up++;
+ } else if (up) {
+ parts.splice(i, 1);
+ up--;
+ }
+ }
+
+ // if the path is allowed to go above the root, restore leading ..s
+ if (allowAboveRoot) {
+ for (; up--; up) {
+ parts.unshift('..');
+ }
+ }
+
+ return parts;
+}
+
+// Split a filename into [root, dir, basename, ext], unix version
+// 'root' is just a slash, or nothing.
+var splitPathRe =
+ /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;
+var splitPath = function(filename) {
+ return splitPathRe.exec(filename).slice(1);
+};
+
+// path.resolve([from ...], to)
+// posix version
+exports.resolve = function() {
+ var resolvedPath = '',
+ resolvedAbsolute = false;
+
+ for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) {
+ var path = (i >= 0) ? arguments[i] : process.cwd();
+
+ // Skip empty and invalid entries
+ if (typeof path !== 'string') {
+ throw new TypeError('Arguments to path.resolve must be strings');
+ } else if (!path) {
+ continue;
+ }
+
+ resolvedPath = path + '/' + resolvedPath;
+ resolvedAbsolute = path.charAt(0) === '/';
+ }
+
+ // At this point the path should be resolved to a full absolute path, but
+ // handle relative paths to be safe (might happen when process.cwd() fails)
+
+ // Normalize the path
+ resolvedPath = normalizeArray(filter(resolvedPath.split('/'), function(p) {
+ return !!p;
+ }), !resolvedAbsolute).join('/');
+
+ return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.';
+};
+
+// path.normalize(path)
+// posix version
+exports.normalize = function(path) {
+ var isAbsolute = exports.isAbsolute(path),
+ trailingSlash = substr(path, -1) === '/';
+
+ // Normalize the path
+ path = normalizeArray(filter(path.split('/'), function(p) {
+ return !!p;
+ }), !isAbsolute).join('/');
+
+ if (!path && !isAbsolute) {
+ path = '.';
+ }
+ if (path && trailingSlash) {
+ path += '/';
+ }
+
+ return (isAbsolute ? '/' : '') + path;
+};
+
+// posix version
+exports.isAbsolute = function(path) {
+ return path.charAt(0) === '/';
+};
+
+// posix version
+exports.join = function() {
+ var paths = Array.prototype.slice.call(arguments, 0);
+ return exports.normalize(filter(paths, function(p, index) {
+ if (typeof p !== 'string') {
+ throw new TypeError('Arguments to path.join must be strings');
+ }
+ return p;
+ }).join('/'));
+};
+
+
+// path.relative(from, to)
+// posix version
+exports.relative = function(from, to) {
+ from = exports.resolve(from).substr(1);
+ to = exports.resolve(to).substr(1);
+
+ function trim(arr) {
+ var start = 0;
+ for (; start < arr.length; start++) {
+ if (arr[start] !== '') break;
+ }
+
+ var end = arr.length - 1;
+ for (; end >= 0; end--) {
+ if (arr[end] !== '') break;
+ }
+
+ if (start > end) return [];
+ return arr.slice(start, end - start + 1);
+ }
+
+ var fromParts = trim(from.split('/'));
+ var toParts = trim(to.split('/'));
+
+ var length = Math.min(fromParts.length, toParts.length);
+ var samePartsLength = length;
+ for (var i = 0; i < length; i++) {
+ if (fromParts[i] !== toParts[i]) {
+ samePartsLength = i;
+ break;
+ }
+ }
+
+ var outputParts = [];
+ for (var i = samePartsLength; i < fromParts.length; i++) {
+ outputParts.push('..');
+ }
+
+ outputParts = outputParts.concat(toParts.slice(samePartsLength));
+
+ return outputParts.join('/');
+};
+
+exports.sep = '/';
+exports.delimiter = ':';
+
+exports.dirname = function(path) {
+ var result = splitPath(path),
+ root = result[0],
+ dir = result[1];
+
+ if (!root && !dir) {
+ // No dirname whatsoever
+ return '.';
+ }
+
+ if (dir) {
+ // It has a dirname, strip trailing slash
+ dir = dir.substr(0, dir.length - 1);
+ }
+
+ return root + dir;
+};
+
+
+exports.basename = function(path, ext) {
+ var f = splitPath(path)[2];
+ // TODO: make this comparison case-insensitive on windows?
+ if (ext && f.substr(-1 * ext.length) === ext) {
+ f = f.substr(0, f.length - ext.length);
+ }
+ return f;
+};
+
+
+exports.extname = function(path) {
+ return splitPath(path)[3];
+};
+
+function filter (xs, f) {
+ if (xs.filter) return xs.filter(f);
+ var res = [];
+ for (var i = 0; i < xs.length; i++) {
+ if (f(xs[i], i, xs)) res.push(xs[i]);
+ }
+ return res;
+}
+
+// String.prototype.substr - negative index don't work in IE8
+var substr = 'ab'.substr(-1) === 'b'
+ ? function (str, start, len) { return str.substr(start, len) }
+ : function (str, start, len) {
+ if (start < 0) start = str.length + start;
+ return str.substr(start, len);
+ }
+;
+
+}).call(this,require('_process'))
+},{"_process":5}],5:[function(require,module,exports){
+// shim for using process in browser
+var process = module.exports = {};
+
+// cached from whatever global is present so that test runners that stub it
+// don't break things. But we need to wrap it in a try catch in case it is
+// wrapped in strict mode code which doesn't define any globals. It's inside a
+// function because try/catches deoptimize in certain engines.
+
+var cachedSetTimeout;
+var cachedClearTimeout;
+
+function defaultSetTimout() {
+ throw new Error('setTimeout has not been defined');
+}
+function defaultClearTimeout () {
+ throw new Error('clearTimeout has not been defined');
+}
+(function () {
+ try {
+ if (typeof setTimeout === 'function') {
+ cachedSetTimeout = setTimeout;
+ } else {
+ cachedSetTimeout = defaultSetTimout;
+ }
+ } catch (e) {
+ cachedSetTimeout = defaultSetTimout;
+ }
+ try {
+ if (typeof clearTimeout === 'function') {
+ cachedClearTimeout = clearTimeout;
+ } else {
+ cachedClearTimeout = defaultClearTimeout;
+ }
+ } catch (e) {
+ cachedClearTimeout = defaultClearTimeout;
+ }
+} ())
+function runTimeout(fun) {
+ if (cachedSetTimeout === setTimeout) {
+ //normal enviroments in sane situations
+ return setTimeout(fun, 0);
+ }
+ // if setTimeout wasn't available but was latter defined
+ if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
+ cachedSetTimeout = setTimeout;
+ return setTimeout(fun, 0);
+ }
+ try {
+ // when when somebody has screwed with setTimeout but no I.E. maddness
+ return cachedSetTimeout(fun, 0);
+ } catch(e){
+ try {
+ // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
+ return cachedSetTimeout.call(null, fun, 0);
+ } catch(e){
+ // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error
+ return cachedSetTimeout.call(this, fun, 0);
+ }
+ }
+
+
+}
+function runClearTimeout(marker) {
+ if (cachedClearTimeout === clearTimeout) {
+ //normal enviroments in sane situations
+ return clearTimeout(marker);
+ }
+ // if clearTimeout wasn't available but was latter defined
+ if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
+ cachedClearTimeout = clearTimeout;
+ return clearTimeout(marker);
+ }
+ try {
+ // when when somebody has screwed with setTimeout but no I.E. maddness
+ return cachedClearTimeout(marker);
+ } catch (e){
+ try {
+ // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
+ return cachedClearTimeout.call(null, marker);
+ } catch (e){
+ // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error.
+ // Some versions of I.E. have different rules for clearTimeout vs setTimeout
+ return cachedClearTimeout.call(this, marker);
+ }
+ }
+
+
+
+}
+var queue = [];
+var draining = false;
+var currentQueue;
+var queueIndex = -1;
+
+function cleanUpNextTick() {
+ if (!draining || !currentQueue) {
+ return;
+ }
+ draining = false;
+ if (currentQueue.length) {
+ queue = currentQueue.concat(queue);
+ } else {
+ queueIndex = -1;
+ }
+ if (queue.length) {
+ drainQueue();
+ }
+}
+
+function drainQueue() {
+ if (draining) {
+ return;
+ }
+ var timeout = runTimeout(cleanUpNextTick);
+ draining = true;
+
+ var len = queue.length;
+ while(len) {
+ currentQueue = queue;
+ queue = [];
+ while (++queueIndex < len) {
+ if (currentQueue) {
+ currentQueue[queueIndex].run();
+ }
+ }
+ queueIndex = -1;
+ len = queue.length;
+ }
+ currentQueue = null;
+ draining = false;
+ runClearTimeout(timeout);
+}
+
+process.nextTick = function (fun) {
+ var args = new Array(arguments.length - 1);
+ if (arguments.length > 1) {
+ for (var i = 1; i < arguments.length; i++) {
+ args[i - 1] = arguments[i];
+ }
+ }
+ queue.push(new Item(fun, args));
+ if (queue.length === 1 && !draining) {
+ runTimeout(drainQueue);
+ }
+};
+
+// v8 likes predictible objects
+function Item(fun, array) {
+ this.fun = fun;
+ this.array = array;
+}
+Item.prototype.run = function () {
+ this.fun.apply(null, this.array);
+};
+process.title = 'browser';
+process.browser = true;
+process.env = {};
+process.argv = [];
+process.version = ''; // empty string to avoid regexp issues
+process.versions = {};
+
+function noop() {}
+
+process.on = noop;
+process.addListener = noop;
+process.once = noop;
+process.off = noop;
+process.removeListener = noop;
+process.removeAllListeners = noop;
+process.emit = noop;
+
+process.binding = function (name) {
+ throw new Error('process.binding is not supported');
+};
+
+process.cwd = function () { return '/' };
+process.chdir = function (dir) {
+ throw new Error('process.chdir is not supported');
+};
+process.umask = function() { return 0; };
+
+},{}],6:[function(require,module,exports){
+module.exports={
+ "name": "ejs",
+ "description": "Embedded JavaScript templates",
+ "keywords": [
+ "template",
+ "engine",
+ "ejs"
+ ],
+ "version": "2.5.7",
+ "author": "Matthew Eernisse (http://fleegix.org)",
+ "contributors": [
+ "Timothy Gu (https://timothygu.github.io)"
+ ],
+ "license": "Apache-2.0",
+ "main": "./lib/ejs.js",
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/mde/ejs.git"
+ },
+ "bugs": "https://github.com/mde/ejs/issues",
+ "homepage": "https://github.com/mde/ejs",
+ "dependencies": {},
+ "devDependencies": {
+ "browserify": "^13.0.1",
+ "eslint": "^3.0.0",
+ "git-directory-deploy": "^1.5.1",
+ "istanbul": "~0.4.3",
+ "jake": "^8.0.0",
+ "jsdoc": "^3.4.0",
+ "lru-cache": "^4.0.1",
+ "mocha": "^3.0.2",
+ "uglify-js": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "scripts": {
+ "test": "jake test",
+ "lint": "eslint \"**/*.js\" Jakefile",
+ "coverage": "istanbul cover node_modules/mocha/bin/_mocha",
+ "doc": "jake doc",
+ "devdoc": "jake doc[dev]"
+ }
+}
+
+},{}]},{},[1])(1)
+});
\ No newline at end of file
diff --git a/rhodecode/public/js/src/ejs_templates/utils.js b/rhodecode/public/js/src/ejs_templates/utils.js
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/js/src/ejs_templates/utils.js
@@ -0,0 +1,58 @@
+// # Copyright (C) 2010-2017 RhodeCode GmbH
+// #
+// # This program is free software: you can redistribute it and/or modify
+// # it under the terms of the GNU Affero General Public License, version 3
+// # (only), as published by the Free Software Foundation.
+// #
+// # This program is distributed in the hope that it will be useful,
+// # but WITHOUT ANY WARRANTY; without even the implied warranty of
+// # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// # GNU General Public License for more details.
+// #
+// # You should have received a copy of the GNU Affero General Public License
+// # along with this program. If not, see .
+// #
+// # This program is dual-licensed. If you wish to learn more about the
+// # RhodeCode Enterprise Edition, including its added features, Support services,
+// # and proprietary license terms, please see https://rhodecode.com/licenses/
+
+var EJS_TEMPLATES = {};
+
+var renderTemplate = function(tmplName, data) {
+ var tmplStr = getTemplate(tmplName);
+ var options = {};
+ var template = ejs.compile(tmplStr, options);
+ return template(data);
+};
+
+
+var registerTemplate = function (name) {
+ if (EJS_TEMPLATES[name] !== undefined) {
+ return
+ }
+
+ var template = $('#ejs_' + name);
+
+ if (template.get(0) !== undefined) {
+ EJS_TEMPLATES[name] = template.html();
+ } else {
+ console.log('Failed to register template', name)
+ }
+};
+
+
+var registerTemplates = function () {
+ $.each($('.ejsTemplate'), function(idx, value) {
+ var id = $(value).attr('id');
+ var tmplId = id.substring(0, 4);
+ var tmplName = id.substring(4);
+ if (tmplId === 'ejs_') {
+ registerTemplate(tmplName)
+ }
+ });
+};
+
+
+var getTemplate = function (name) {
+ return EJS_TEMPLATES[name]
+};
diff --git a/rhodecode/public/js/src/rhodecode/pullrequests.js b/rhodecode/public/js/src/rhodecode/pullrequests.js
--- a/rhodecode/public/js/src/rhodecode/pullrequests.js
+++ b/rhodecode/public/js/src/rhodecode/pullrequests.js
@@ -137,10 +137,10 @@ ReviewersController = function () {
}
if (data.rules.voting !== undefined) {
- if (data.rules.voting < 0){
+ if (data.rules.voting < 0) {
self.$rulesList.append(
self.addRule(
- _gettext('All reviewers must vote.'))
+ _gettext('All individual reviewers must vote.'))
)
} else if (data.rules.voting === 1) {
self.$rulesList.append(
@@ -155,6 +155,15 @@ ReviewersController = function () {
)
}
}
+
+ if (data.rules.voting_groups !== undefined) {
+ $.each(data.rules.voting_groups, function(index, rule_data) {
+ self.$rulesList.append(
+ self.addRule(rule_data.text)
+ )
+ });
+ }
+
if (data.rules.use_code_authors_for_review) {
self.$rulesList.append(
self.addRule(
@@ -227,10 +236,7 @@ ReviewersController = function () {
for (var i = 0; i < data.reviewers.length; i++) {
var reviewer = data.reviewers[i];
self.addReviewMember(
- reviewer.user_id, reviewer.first_name,
- reviewer.last_name, reviewer.username,
- reviewer.gravatar_link, reviewer.reasons,
- reviewer.mandatory);
+ reviewer, reviewer.reasons, reviewer.mandatory);
}
$('.calculate-reviewers').hide();
prButtonLock(false, null, 'reviewers');
@@ -260,64 +266,22 @@ ReviewersController = function () {
$('#reviewer_{0}'.format(reviewer_id)).remove();
}
};
+ this.reviewMemberEntry = function() {
- this.addReviewMember = function(id, fname, lname, nname, gravatar_link, reasons, mandatory) {
+ };
+ this.addReviewMember = function(reviewer_obj, reasons, mandatory) {
var members = self.$reviewMembers.get(0);
- var reasons_html = '';
- var reasons_inputs = '';
+ var id = reviewer_obj.user_id;
+ var username = reviewer_obj.username;
+
var reasons = reasons || [];
var mandatory = mandatory || false;
- if (reasons) {
- for (var i = 0; i < reasons.length; i++) {
- reasons_html += '- {0}
'.format(reasons[i]);
- reasons_inputs += '';
- }
- }
- var tmpl = '' +
- ''+
- ''+
- ''+
- ''+
- '{1}'+
- reasons_html +
- ''+
- ''+
- '{3}'+
- '';
-
- if (mandatory) {
- tmpl += ''+
- '' +
- ''+
- '
' +
- ''+
- '' +
- ''+
- '
';
-
- } else {
- tmpl += ''+
- ''+
- '' +
- ''+
- '
';
- }
- // continue template
- tmpl += ''+
- ''+
- '' ;
-
- var displayname = "{0} ({1} {2})".format(
- nname, escapeHtml(fname), escapeHtml(lname));
- var element = tmpl.format(gravatar_link,displayname,id,reasons_inputs);
- // check if we don't have this ID already in
- var ids = [];
+ // register IDS to check if we don't have this ID already in
+ var currentIds = [];
var _els = self.$reviewMembers.find('li').toArray();
for (el in _els){
- ids.push(_els[el].id)
+ currentIds.push(_els[el].id)
}
var userAllowedReview = function(userId) {
@@ -333,19 +297,29 @@ ReviewersController = function () {
var userAllowed = userAllowedReview(id);
if (!userAllowed){
- alert(_gettext('User `{0}` not allowed to be a reviewer').format(nname));
- }
- var shouldAdd = userAllowed && ids.indexOf('reviewer_'+id) == -1;
+ alert(_gettext('User `{0}` not allowed to be a reviewer').format(username));
+ } else {
+ // only add if it's not there
+ var alreadyReviewer = currentIds.indexOf('reviewer_'+id) != -1;
- if(shouldAdd) {
- // only add if it's not there
- members.innerHTML += element;
+ if (alreadyReviewer) {
+ alert(_gettext('User `{0}` already in reviewers').format(username));
+ } else {
+ members.innerHTML += renderTemplate('reviewMemberEntry', {
+ 'member': reviewer_obj,
+ 'mandatory': mandatory,
+ 'allowed_to_update': true,
+ 'review_status': 'not_reviewed',
+ 'review_status_label': _gettext('Not Reviewed'),
+ 'reasons': reasons
+ });
+ }
}
};
this.updateReviewers = function(repo_name, pull_request_id){
- var postData = '_method=put&' + $('#reviewers input').serialize();
+ var postData = $('#reviewers input').serialize();
_updatePullRequest(repo_name, pull_request_id, postData);
};
@@ -457,21 +431,30 @@ var ReviewerAutoComplete = function(inpu
formatResult: autocompleteFormatResult,
lookupFilter: autocompleteFilterResult,
onSelect: function(element, data) {
+ var mandatory = false;
+ var reasons = [_gettext('added manually by "{0}"').format(templateContext.rhodecode_user.username)];
- var reasons = [_gettext('added manually by "{0}"').format(templateContext.rhodecode_user.username)];
+ // add whole user groups
if (data.value_type == 'user_group') {
reasons.push(_gettext('member of "{0}"').format(data.value_display));
$.each(data.members, function(index, member_data) {
- reviewersController.addReviewMember(
- member_data.id, member_data.first_name, member_data.last_name,
- member_data.username, member_data.icon_link, reasons);
+ var reviewer = member_data;
+ reviewer['user_id'] = member_data['id'];
+ reviewer['gravatar_link'] = member_data['icon_link'];
+ reviewer['user_link'] = member_data['profile_link'];
+ reviewer['rules'] = [];
+ reviewersController.addReviewMember(reviewer, reasons, mandatory);
})
-
- } else {
- reviewersController.addReviewMember(
- data.id, data.first_name, data.last_name,
- data.username, data.icon_link, reasons);
+ }
+ // add single user
+ else {
+ var reviewer = data;
+ reviewer['user_id'] = data['id'];
+ reviewer['gravatar_link'] = data['icon_link'];
+ reviewer['user_link'] = data['profile_link'];
+ reviewer['rules'] = [];
+ reviewersController.addReviewMember(reviewer, reasons, mandatory);
}
$(inputId).val('');
diff --git a/rhodecode/templates/base/base.mako b/rhodecode/templates/base/base.mako
--- a/rhodecode/templates/base/base.mako
+++ b/rhodecode/templates/base/base.mako
@@ -1,6 +1,8 @@
## -*- coding: utf-8 -*-
<%inherit file="root.mako"/>
+<%include file="/ejs_templates/templates.html"/>
+