# HG changeset patch # User Marcin Kuzminski # Date 2018-01-19 11:38:25 # Node ID 3775edd6faa28cee8a341bec8d827666419b9291 # Parent aae8d7166c9223e1dd38c22835dc08f7c222f616 default-reviewers: introduce new voting rule logic that allows defining vote rules for user groups assigned to the default reviewers. - allows to set per number votes on user groups - group reviewers by groups - store the rules and show them in the UI - fixes #5409 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 = '' + - '
  • '+ - ''+ - '
    '+ - '
    '+ - '
    '+ - 'gravatar'+ - '{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"/> +
    diff --git a/rhodecode/templates/ejs_templates/templates.html b/rhodecode/templates/ejs_templates/templates.html new file mode 100644 --- /dev/null +++ b/rhodecode/templates/ejs_templates/templates.html @@ -0,0 +1,109 @@ +<%text> +
    + + + + + + + +
    + + + + \ No newline at end of file diff --git a/rhodecode/templates/pullrequests/pullrequest.mako b/rhodecode/templates/pullrequests/pullrequest.mako --- a/rhodecode/templates/pullrequests/pullrequest.mako +++ b/rhodecode/templates/pullrequests/pullrequest.mako @@ -432,7 +432,7 @@ // generate new DESC of target repo displayed next to select var prLink = pyroutes.url('pullrequest_new', {'repo_name': repoData['name']}); $('#target_repo_desc').html( - "${_('Target repository')}: {0}. Use as source".format(repoData['description'], prLink) + "${_('Target repository')}: {0}. Switch base, and use as source.".format(repoData['description'], prLink) ); // generate dynamic select2 for refs. diff --git a/rhodecode/templates/pullrequests/pullrequest_show.mako b/rhodecode/templates/pullrequests/pullrequest_show.mako --- a/rhodecode/templates/pullrequests/pullrequest_show.mako +++ b/rhodecode/templates/pullrequests/pullrequest_show.mako @@ -340,58 +340,42 @@ ## REVIEWERS
    - ${_('Pull request reviewers')} / ${_('show reasons')} + ${_('Pull request reviewers')} %if c.allowed_to_update: ${_('Edit')} %endif
    - ## members goes here ! + + ## members redering block
      - %for member,reasons,mandatory,status in c.pull_request_reviewers: -
    • -
      -
      -
      -
      -
      - ${self.gravatar_with_user(member.email, 16)} -
      - - - % if reasons: -
      - %for reason in reasons: - - - %endfor -
      - % endif - - - - - % if mandatory: -
      - -
      -
      - -
      - % else: - %if c.allowed_to_update: - - %endif - % endif -
      -
    • - %endfor + + % for review_obj, member, reasons, mandatory, status in c.pull_request_reviewers: + + + % endfor +
    + ## end members redering block %if not c.pull_request.is_closed():