diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 8fa46a457012d86676fd4a799863080264dc89c9..6970bb4e7c8eca54b88fee8365d42d0f50180c5b 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -62,7 +62,7 @@ Lint/UnusedMethodArgument: # Offense count: 112 # Configuration parameters: CountComments. Metrics/BlockLength: - Max: 303 + Enabled: false # Offense count: 4 # Cop supports --auto-correct. @@ -125,7 +125,7 @@ RSpec/MessageSpies: # Offense count: 3278 RSpec/MultipleExpectations: - Max: 37 + Enabled: false # Offense count: 2287 RSpec/NamedSubject: diff --git a/app/assets/images/mailers/approval/icon-merge-request-gray.gif b/app/assets/images/mailers/approval/icon-merge-request-gray.gif new file mode 100644 index 0000000000000000000000000000000000000000..30cbe66980ffb906ffeaadaf5ded34d82d293637 Binary files /dev/null and b/app/assets/images/mailers/approval/icon-merge-request-gray.gif differ diff --git a/app/assets/images/mailers/approval/icon-x-orange-inverted.gif b/app/assets/images/mailers/approval/icon-x-orange-inverted.gif new file mode 100644 index 0000000000000000000000000000000000000000..7fbf1c413845c1befcd3314a8ea6b74c4e594ff5 Binary files /dev/null and b/app/assets/images/mailers/approval/icon-x-orange-inverted.gif differ diff --git a/app/assets/javascripts/generic_bundles/vue_resource.js.es6 b/app/assets/javascripts/generic_bundles/vue_resource.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..eff1dcabfa2d5b381b3c519711c9cc90f704f84a --- /dev/null +++ b/app/assets/javascripts/generic_bundles/vue_resource.js.es6 @@ -0,0 +1,2 @@ +//= require vue +//= require vue-resource diff --git a/app/assets/javascripts/merge_request_widget/approvals/approvals_api.js.es6 b/app/assets/javascripts/merge_request_widget/approvals/approvals_api.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..4f181e02f3939afb356e76f38bfaccb02ef36b3a --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/approvals/approvals_api.js.es6 @@ -0,0 +1,36 @@ +/* global Vue, Flash */ +//= require ./approvals_store + +(() => { + class ApprovalsApi { + constructor(endpoint) { + gl.ApprovalsApi = this; + this.init(endpoint); + } + + init(mergeRequestEndpoint) { + this.baseEndpoint = `${mergeRequestEndpoint}/approvals`; + Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken(); + } + + fetchApprovals() { + const flashErrorMessage = 'An error occured while retrieving approval data for this merge request.'; + + return Vue.http.get(this.baseEndpoint).catch(() => new Flash(flashErrorMessage)); + } + + approveMergeRequest() { + const flashErrorMessage = 'An error occured while submitting your approval.'; + + return Vue.http.post(this.baseEndpoint).catch(() => new Flash(flashErrorMessage)); + } + + unapproveMergeRequest() { + const flashErrorMessage = 'An error occured while removing your approval.'; + + return Vue.http.delete(this.baseEndpoint).catch(() => new Flash(flashErrorMessage)); + } + } + + gl.ApprovalsApi = ApprovalsApi; +})(); diff --git a/app/assets/javascripts/merge_request_widget/approvals/approvals_bundle.js.es6 b/app/assets/javascripts/merge_request_widget/approvals/approvals_bundle.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..e5173098f9356a9bdfa71d89afda859eaca89d48 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/approvals/approvals_bundle.js.es6 @@ -0,0 +1,3 @@ +//= require ./approvals_store +//= require ./approvals_api +//= require_directory ./components diff --git a/app/assets/javascripts/merge_request_widget/approvals/approvals_store.js.es6 b/app/assets/javascripts/merge_request_widget/approvals/approvals_store.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..1c96109656b6094eb470ec10f0fe09d0060788e8 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/approvals/approvals_store.js.es6 @@ -0,0 +1,65 @@ +/* global Vue */ +//= require ./approvals_api + +(() => { + let singleton; + + class MergeRequestApprovalsStore { + constructor(rootStore) { + if (!singleton) { + singleton = this; + this.init(rootStore); + } + return singleton; + } + + init(rootStore) { + this.rootStore = rootStore; + this.api = new gl.ApprovalsApi(rootStore.rootEl.dataset.endpoint); + this.state = { + fetching: false, + }; + } + + initStoreOnce() { + const state = this.state; + if (!state.fetching) { + state.fetching = true; + return this.fetch() + .then(() => { + state.fetching = false; + this.assignToRootStore('showApprovals', true); + }); + } + return Promise.resolve(); + } + + fetch() { + return this.api.fetchApprovals() + .then(res => this.assignToRootStore('approvals', res.data)) + .then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left)); + } + + approve() { + return this.api.approveMergeRequest() + .then(res => this.assignToRootStore('approvals', res.data)) + .then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left)); + } + + unapprove() { + return this.api.unapproveMergeRequest() + .then(res => this.assignToRootStore('approvals', res.data)) + .then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left)); + } + + setMergeRequestAcceptanceStatus(approvalsLeft) { + return this.rootStore.assignToData('disableAcceptance', !!approvalsLeft); + } + + assignToRootStore(key, data) { + return this.rootStore.assignToData(key, data); + } + } + gl.MergeRequestApprovalsStore = MergeRequestApprovalsStore; +})(window.gl || (window.gl = {})); + diff --git a/app/assets/javascripts/merge_request_widget/approvals/components/approvals_body.js.es6 b/app/assets/javascripts/merge_request_widget/approvals/components/approvals_body.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..7451639de35c2839e7e1ad202dc7d98ad300d5ef --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/approvals/components/approvals_body.js.es6 @@ -0,0 +1,94 @@ +/* global Vue */ +//= require ../approvals_store +//= require ../approvals_api + +(() => { + Vue.component('approvals-body', { + name: 'approvals-body', + props: { + approvedBy: { + type: Array, + required: false, + }, + approvalsLeft: { + type: Number, + required: false, + }, + userCanApprove: { + type: Boolean, + required: false, + }, + userHasApproved: { + type: Boolean, + required: false, + }, + suggestedApprovers: { + type: Array, + required: false, + }, + }, + data() { + return { + approving: false, + }; + }, + computed: { + approvalsRequiredStringified() { + const baseString = `${this.approvalsLeft} more approval`; + return this.approvalsLeft === 1 ? baseString : `${baseString}s`; + }, + approverNamesStringified() { + const approvers = this.suggestedApprovers; + + if (!approvers) { + return ''; + } + + return approvers.length === 1 ? approvers[0].name : + approvers.reduce((memo, curr, index) => { + const nextMemo = `${memo}${curr.name}`; + + if (index === approvers.length - 2) { // second to last index + return `${nextMemo} or `; + } else if (index === approvers.length - 1) { // last index + return nextMemo; + } + + return `${nextMemo}, `; + }, ''); + }, + showApproveButton() { + return this.userCanApprove && !this.userHasApproved; + }, + showSuggestedApprovers() { + return this.suggestedApprovers && this.suggestedApprovers.length; + }, + }, + methods: { + approveMergeRequest() { + this.approving = true; + return gl.ApprovalsStore.approve().then(() => { + this.approving = false; + }); + }, + }, + beforeCreate() { + gl.ApprovalsStore.initStoreOnce(); + }, + template: ` + + `, + }); +})(); diff --git a/app/assets/javascripts/merge_request_widget/approvals/components/approvals_footer.js.es6 b/app/assets/javascripts/merge_request_widget/approvals/components/approvals_footer.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..dfa9c62e3440d9c2c9835a9248b79460082aee77 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/approvals/components/approvals_footer.js.es6 @@ -0,0 +1,93 @@ +/* global Vue */ +//= require ../approvals_store +//= require vue_common_component/link_to_member_avatar + +(() => { + Vue.component('approvals-footer', { + name: 'approvals-footer', + props: { + approvedBy: { + type: Array, + required: false, + }, + approvalsLeft: { + type: Number, + required: false, + }, + userCanApprove: { + type: Boolean, + required: false, + }, + userHasApproved: { + type: Boolean, + required: false, + }, + suggestedApprovers: { + type: Array, + required: false, + }, + pendingAvatarSvg: { + type: String, + required: true, + }, + checkmarkSvg: { + type: String, + required: true, + }, + }, + data() { + return { + unapproving: false, + }; + }, + computed: { + showUnapproveButton() { + return this.userHasApproved && !this.userCanApprove; + }, + }, + methods: { + unapproveMergeRequest() { + this.unapproving = true; + gl.ApprovalsStore.unapprove().then(() => { + this.unapproving = false; + }); + }, + }, + beforeCreate() { + gl.ApprovalsStore.initStoreOnce(); + }, + template: ` + + `, + }); +})(); + diff --git a/app/assets/javascripts/merge_request_widget/widget_bundle.js.es6 b/app/assets/javascripts/merge_request_widget/widget_bundle.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..0986ae242ecd34ec022f3d267743b30196cf30d6 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/widget_bundle.js.es6 @@ -0,0 +1,15 @@ +/* global Vue */ +//= require ./widget_store +//= require ./approvals/approvals_bundle + +(() => { + $(() => { + const rootEl = document.getElementById('merge-request-widget-app'); + const widgetSharedStore = new gl.MergeRequestWidgetStore(rootEl); + + gl.MergeRequestWidgetApp = new Vue({ + el: rootEl, + data: widgetSharedStore.data, + }); + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_request_widget/widget_store.js.es6 b/app/assets/javascripts/merge_request_widget/widget_store.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..a934712f5b92459bcb51d831895d368872d69320 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget/widget_store.js.es6 @@ -0,0 +1,40 @@ +//= require ./approvals/approvals_store + +(() => { + let singleton; + + class MergeRequestWidgetStore { + constructor(rootEl) { + if (!singleton) { + singleton = gl.MergeRequestWidget.Store = this; + this.init(rootEl); + } + return singleton; + } + + init(rootEl) { + this.rootEl = rootEl; + this.data = {}; + + // init other widget stores here + this.initWidgetState(); + this.initApprovals(); + } + + initWidgetState() { + this.assignToData('showApprovals', false); + this.assignToData('disableAcceptance', Boolean(this.rootEl.dataset.approvalPending)); + } + + initApprovals() { + gl.ApprovalsStore = new gl.MergeRequestApprovalsStore(this); + this.assignToData('approvals', {}); + } + + assignToData(key, val) { + this.data[key] = val; + return val; + } + } + gl.MergeRequestWidgetStore = MergeRequestWidgetStore; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_common_component/link_to_member_avatar.js.es6 b/app/assets/javascripts/vue_common_component/link_to_member_avatar.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..f36591c6181cd9c506b22d3d76f133a8edafc3e6 --- /dev/null +++ b/app/assets/javascripts/vue_common_component/link_to_member_avatar.js.es6 @@ -0,0 +1,92 @@ +/* global Vue */ +// Analogue of link_to_member_avatar in app/helpers/projects_helper.rb + +(() => { + Vue.component('link-to-member-avatar', { + props: { + avatarUrl: { + type: String, + required: false, + default: '/assets/no_avatar.png', + }, + profileUrl: { + type: String, + required: false, + default: '', + }, + displayName: { + type: String, + required: false, + }, + extraAvatarClass: { + type: String, + default: '', + required: false, + }, + extraLinkClass: { + type: String, + default: '', + required: false, + }, + showTooltip: { + type: Boolean, + required: false, + default: true, + }, + clickable: { + type: Boolean, + default: true, + required: false, + }, + tooltipContainer: { + type: String, + required: false, + }, + avatarHtml: { + type: String, + required: false, + }, + avatarSize: { + type: Number, + required: false, + default: 32, + }, + }, + data() { + return { + avatarBaseClass: 'avatar avatar-inline', + }; + }, + computed: { + avatarSizeClass() { + return `s${this.avatarSize}`; + }, + avatarHtmlClass() { + return `${this.avatarSizeClass} ${this.avatarBaseClass}`; + }, + tooltipClass() { + return this.showTooltip ? 'has-tooltip' : ''; + }, + avatarClass() { + return `${this.avatarBaseClass} ${this.avatarSizeClass} ${this.extraAvatarClass}`; + }, + disabledClass() { + return !this.clickable ? 'disabled' : ''; + }, + linkClass() { + return `author_link ${this.tooltipClass} ${this.extraLinkClass} ${this.disabledClass}`; + }, + tooltipContainerAttr() { + return this.tooltipContainer || 'body'; + }, + }, + template: ` + + `, + }); +})(); diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 45ff9f7ff5f34343bff8f0625270953273a680a0..e6b66036f9496b5ec0acd5a825d1e92830f33646 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -440,3 +440,93 @@ padding-right: 0; } } + +#merge-request-widget-app .loading { + padding-top: 5px; +} + +#merge-request-widget-app .loading, +.approvals-components { + border-top: 1px solid $well-inner-border; +} + +.approvals-body { + @media (max-width: $screen-xs-max) { + text-align: center; + } + + .approve-btn { + margin-top: 10px; + } +} + +.approvals-footer { + display: flex; + + // vertically centers all children + > span { + align-self: center; + } + + .hide-asset { + img { + display: none; + } + + svg { + margin-bottom: -7px; // makes up for border removed + border: none; + } + } + + .approvers-prefix { + margin-right: 5px; + } + + .unapprove-btn-wrap { + border-left: 1px solid $gray-darker; + padding-left: 5px; + margin-left: 10px; + } + + .unapprove-btn { + border: none; + background: transparent; + cursor: pointer; + + &:hover { + color: $gl-text-color-secondary; + text-decoration: none; + } + + &:focus { + outline: none; + } + } + + // styles for approver avatar checkmark + .approver-avatar { + position: relative; + display: inline-block; + + svg.avatar { + position: absolute; + top: 12%; + right: 4%; + height: 45%; + width: 45%; + } + } +} + +.link-to-member-avatar { + .disabled { + pointer-events: none; + cursor: default; + } + + .avatar { + margin-bottom: -2px; + margin-right: 3px; + } +} diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 44cd2466d483f98ce0760681fec6a3bb42b4be5a..0d0f81f93ce402b8c4cd8040bd821b9534089105 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -11,7 +11,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check, :ci_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, - :approve, :rebase + # EE + :approve, :approvals, :unapprove, :rebase ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] @@ -481,15 +482,38 @@ def approve return render_404 end - MergeRequests::ApprovalService. + ::MergeRequests::ApprovalService. new(project, current_user). execute(@merge_request) - redirect_to merge_request_path(@merge_request) + render_approvals_json + end + + def approvals + render_approvals_json + end + + def unapprove + if @merge_request.has_approved?(current_user) + ::MergeRequests::RemoveApprovalService. + new(project, current_user). + execute(@merge_request) + end + + render_approvals_json end protected + def render_approvals_json + respond_to do |format| + format.json do + entity = API::Entities::MergeRequestApprovals.new(@merge_request, current_user: current_user) + render json: entity + end + end + end + def selected_target_project if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil? @project diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 759d85fdb318e88559f516a2841e50142c409074..568dabb1e4b5591e7f4ed07053490dc037d69684 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -90,6 +90,7 @@ def render_items_list(items, separator = "and") end end + # This may be able to be removed with associated specs def render_require_section(merge_request) str = if merge_request.approvals_left == 1 "Requires one more approval" diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 5bd8c37ed7b3ae08d3dfd9989e22f613923e7088..73dea209b88879b5853172781a18f0b9dc3054ce 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -54,11 +54,18 @@ def add_merge_request_approver_email(recipient_id, merge_request_id, updated_by_ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end - def approved_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) + def approved_merge_request_email(recipient_id, merge_request_id, approved_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - @approved_by_users = @merge_request.approved_by_users.map(&:name) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) + @approved_by = User.find(approved_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(approved_by_user_id, recipient_id)) + end + + def unapproved_merge_request_email(recipient_id, merge_request_id, unapproved_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + + @unapproved_by = User.find(unapproved_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(unapproved_by_user_id, recipient_id)) end def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id) diff --git a/app/models/concerns/approvable.rb b/app/models/concerns/approvable.rb index 561a38392766b5e4ff7536ee2b728c6f4872839d..a34eac68f23ebc42d7d4ecd8fb507f5fc75b95d7 100644 --- a/app/models/concerns/approvable.rb +++ b/app/models/concerns/approvable.rb @@ -123,6 +123,12 @@ def can_approve?(user) any_approver_allowed? && approvals.where(user: user).empty? end + def has_approved?(user) + return false unless user + + approved_by_users.include?(user) + end + # Once there are fewer approvers left in the list than approvals required, allow other # project members to approve the MR. # diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8ae6651e59bdd11134c0517f21d4d5046dbd92b2 --- /dev/null +++ b/app/services/merge_requests/remove_approval_service.rb @@ -0,0 +1,31 @@ +module MergeRequests + class RemoveApprovalService < MergeRequests::BaseService + def execute(merge_request) + # paranoid protection against running wrong deletes + return unless merge_request.id && current_user.id + + approval = merge_request.approvals.where(user: current_user) + + currently_approved = merge_request.approved? + + if approval.destroy_all + # bust the cache here, otherwise will show results from + # before the deletion + merge_request.approvals(true) + + create_note(merge_request) + + if currently_approved + notification_service.unapprove_mr(merge_request, current_user) + execute_hooks(merge_request, 'unapproved') + end + end + end + + private + + def create_note(merge_request) + SystemNoteService.unapprove_mr(merge_request, current_user) + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 89396156d0c66fc779f67fc43978aef12d543c6c..34982ff416795baf80a03bb9229bfcb47ac6ff01 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -162,6 +162,10 @@ def approve_mr(merge_request, current_user) approve_mr_email(merge_request, merge_request.target_project, current_user) end + def unapprove_mr(merge_request, current_user) + unapprove_mr_email(merge_request, merge_request.target_project, current_user) + end + def resolve_all_discussions(merge_request, current_user) recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions") @@ -608,6 +612,14 @@ def approve_mr_email(merge_request, project, current_user) end end + def unapprove_mr_email(merge_request, project, current_user) + recipients = build_recipients(merge_request, project, current_user) + + recipients.each do |recipient| + mailer.unapproved_merge_request_email(recipient.id, merge_request.id, current_user.id).deliver_later + end + end + def add_mr_approvers_email(merge_request, approvers, current_user) approvers.each do |approver| recipient = approver.user diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c6c9d006809f17538b85a2be93871e762b7de1f1..9e6a3c8f31d566159691297d74259fc0bac26638 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -474,6 +474,11 @@ def approve_mr(noteable, user) create_note(noteable: noteable, project: noteable.project, author: user, note: body) end + def unapprove_mr(noteable, user) + body = "Unapproved this merge request" + create_note(noteable: noteable, project: noteable.project, author: user, note: body) + end + private def notes_for_mentioner(mentioner, noteable, notes) diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml index de2c3818494f0cfdd9512d32439e52f64c1db515..16b9df65f5ec120ce975f0dca94c1aa6d8cb2221 100644 --- a/app/views/notify/approved_merge_request_email.html.haml +++ b/app/views/notify/approved_merge_request_email.html.haml @@ -1,2 +1,146 @@ -%p - = "Merge Request #{@merge_request.to_reference} was approved by #{@approved_by_users.to_sentence}" + +%html{ lang: "en" } + %head + %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ + %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ + %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/ + %title= message.subject + :css + /* CLIENT-SPECIFIC STYLES */ + body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } + table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } + img { -ms-interpolation-mode: bicubic; } + + /* iOS BLUE LINKS */ + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + + /* ANDROID MARGIN HACK */ + body { margin:0 !important; } + div[style*="margin: 16px 0"] { margin:0 !important; } + + @media only screen and (max-width: 639px) { + body, #body { + min-width: 320px !important; + } + table.wrapper { + width: 100% !important; + min-width: 320px !important; + } + table.wrapper > tbody > tr > td { + border-left: 0 !important; + border-right: 0 !important; + border-radius: 0 !important; + padding-left: 10px !important; + padding-right: 10px !important; + } + } + %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } + %tbody + %tr.line + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %tr.header + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "50", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo.gif'), width: "55" }/ + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %tbody + %tr.success + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#31af64;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } + %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } + %span Merge request was approved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required}) + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } +   + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;line-height:1.4;text-align:center;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" } + %tbody + %tr{ style: 'width:100%;' } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } + %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } + %span{ style: "font-weight: bold;color:#333333;" } Merge request + %a{ href: merge_request_url(@merge_request), style: "font-weight: bold;color:#3777b0;text-decoration:none" } #{@merge_request.to_reference} + %span was approved by + %img.avatar{ height: "24", src: avatar_icon(@approved_by, 24), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/ + %a.muted{ href: user_url(@approved_by), style: "color:#333333;text-decoration:none;" } + = @approved_by.name + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } +   + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } + - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name + - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) + %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } + = namespace_name + \/ + %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } + = @project.name + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %span.muted{ style: "color:#333333;text-decoration:none;" } + = @merge_request.source_branch + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(@merge_request.author, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %a.muted{ href: user_url(@merge_request.author), style: "color:#333333;text-decoration:none;" } + = @merge_request.author.name + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Assignee + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(@merge_request.assignee, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %a.muted{ href: user_url(@merge_request.assignee), style: "color:#333333;text-decoration:none;" } + = @merge_request.assignee.name + + %tr.footer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "33", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo-full-horizontal.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/ + %div + %a{ href: profile_notifications_url, style: "color:#3777b0;text-decoration:none;" } Manage all notifications + · + %a{ href: help_url, style: "color:#3777b0;text-decoration:none;" } Help + %div + You're receiving this email because of your account on + = succeed "." do + %a{ href: root_url, style: "color:#3777b0;text-decoration:none;" }= Gitlab.config.gitlab.host diff --git a/app/views/notify/approved_merge_request_email.text.haml b/app/views/notify/approved_merge_request_email.text.haml index 5038a70e3864f9510ceab266e752994e507f1bfa..1b7af953b102d409ffcece988cd050d706c35687 100644 --- a/app/views/notify/approved_merge_request_email.text.haml +++ b/app/views/notify/approved_merge_request_email.text.haml @@ -1,4 +1,4 @@ -= "Merge Request #{@merge_request.to_reference} was approved by #{@approved_by_users.to_sentence}" += "Merge Request #{@merge_request.to_reference} was approved by #{@approved_by_user}" Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)} diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b7c5e9a89a96d49a7d3f4b92aa32fda74e8c2872 --- /dev/null +++ b/app/views/notify/unapproved_merge_request_email.html.haml @@ -0,0 +1,146 @@ + +%html{ lang: "en" } + %head + %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ + %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ + %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/ + %title= message.subject + :css + /* CLIENT-SPECIFIC STYLES */ + body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } + table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } + img { -ms-interpolation-mode: bicubic; } + + /* iOS BLUE LINKS */ + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + + /* ANDROID MARGIN HACK */ + body { margin:0 !important; } + div[style*="margin: 16px 0"] { margin:0 !important; } + + @media only screen and (max-width: 639px) { + body, #body { + min-width: 320px !important; + } + table.wrapper { + width: 100% !important; + min-width: 320px !important; + } + table.wrapper > tbody > tr > td { + border-left: 0 !important; + border-right: 0 !important; + border-radius: 0 !important; + padding-left: 10px !important; + padding-right: 10px !important; + } + } + %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } + %tbody + %tr.line + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %tr.header + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "50", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo.gif'), width: "55" }/ + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %tbody + %tr.success + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#FC6D26;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } + %img{ alt: "✗", height: "13", src: image_url('mailers/approval/icon-x-orange-inverted.gif'), style: "display:block;", width: "13" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } + %span Merge request was unapproved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required}) + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } +   + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;line-height:1.4;text-align:center;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" } + %tbody + %tr{ style: 'width:100%;' } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } + %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } + %span{ style: "font-weight: bold;color:#333333;" } Merge request + %a{ href: merge_request_url(@merge_request), style: "font-weight: bold;color:#3777b0;text-decoration:none" } #{@merge_request.to_reference} + %span was unapproved by + %img.avatar{ height: "24", src: avatar_icon(@unapproved_by, 24), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/ + %a.muted{ href: user_url(@unapproved_by), style: "color:#333333;text-decoration:none;" } + = @unapproved_by.name + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } +   + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } + - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name + - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) + %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } + = namespace_name + \/ + %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } + = @project.name + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %span.muted{ style: "color:#333333;text-decoration:none;" } + = @merge_request.source_branch + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(@merge_request.author, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %a.muted{ href: user_url(@merge_request.author), style: "color:#333333;text-decoration:none;" } + = @merge_request.author.name + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Assignee + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(@merge_request.assignee, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %a.muted{ href: user_url(@merge_request.assignee), style: "color:#333333;text-decoration:none;" } + = @merge_request.assignee.name + + %tr.footer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "33", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo-full-horizontal.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/ + %div + %a{ href: profile_notifications_url, style: "color:#3777b0;text-decoration:none;" } Manage all notifications + · + %a{ href: help_url, style: "color:#3777b0;text-decoration:none;" } Help + %div + You're receiving this email because of your account on + = succeed "." do + %a{ href: root_url, style: "color:#3777b0;text-decoration:none;" }= Gitlab.config.gitlab.host diff --git a/app/views/notify/unapproved_merge_request_email.text.haml b/app/views/notify/unapproved_merge_request_email.text.haml new file mode 100644 index 0000000000000000000000000000000000000000..7e31b2f45e6c2ae3605af76aeb33b06dfc9c45d0 --- /dev/null +++ b/app/views/notify/unapproved_merge_request_email.text.haml @@ -0,0 +1,8 @@ += "Merge Request #{@merge_request.to_reference} was unapproved by #{@unapproved_by_user}" + +Merge Request url: #{namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)} + += merge_path_description(@merge_request, 'to') + +Author: #{@merge_request.author_name} +Assignee: #{@merge_request.assignee_name} diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 0516801cdf38fffa7afb7d478d101ac37e66c2ab..24ca7bc3fd111967aea0939a95b7a676c7171f28 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -108,8 +108,8 @@ = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title :javascript - var merge_request; - - merge_request = new MergeRequest({ - action: "#{controller.action_name}" + $(function () { + new MergeRequest({ + action: "#{controller.action_name}" + }); }); diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index bfc096cfe6382b2bbfe3f36083a2d1d3148908f3..a2127549a903b1cc27be1e4dc6001420039ff412 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -1,4 +1,9 @@ -.mr-state-widget +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('merge_request_widget/widget_bundle.js') + +- approval_pending = @merge_request.requires_approve? && !@merge_request.approved? + +#merge-request-widget-app.mr-state-widget{ 'data-endpoint'=> merge_request_path(@merge_request), 'data-approval-pending' => approval_pending } = render 'projects/merge_requests/widget/heading' .mr-widget-body -# After conflicts are resolved, the user is redirected back to the MR page. @@ -23,8 +28,6 @@ = render 'projects/merge_requests/widget/open/conflicts' - elsif @merge_request.work_in_progress? = render 'projects/merge_requests/widget/open/wip' - - elsif @merge_request.requires_approve? && !@merge_request.approved? - = render 'projects/merge_requests/widget/open/approve' - elsif @merge_request.merge_when_build_succeeds? = render 'projects/merge_requests/widget/open/merge_when_build_succeeds' - elsif !@merge_request.can_be_merged_by?(current_user) @@ -52,8 +55,12 @@ != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed. - - if @merge_request.approvals.any? - .mr-widget-footer.approved-by-users - Approved by - - @merge_request.approved_by_users.each do |user| - = link_to_member(@project, user, name: false, size: 24) + - if @merge_request.requires_approve? + .mr-widget-footer{ 'v-show' => '!showApprovals' } + = icon("spinner spin") + Checking approval status for this merge request. + .approvals-components{ 'v-show' => 'showApprovals' } + = render 'projects/merge_requests/widget/open/approvals_body' + = render 'projects/merge_requests/widget/open/approvals_footer' + + diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index 81125aa0dac00ccfee21e2ba8894c27a7dc15cea..5daeb6da6fdaddb910f167d5b18a68437a3116aa 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -28,7 +28,7 @@ = icon('warning fw') Merge Immediately - else - = f.button class: "btn btn-create btn-grouped js-merge-button accept_merge_request #{status_class}" do + = f.button class: "btn btn-create btn-grouped js-merge-button accept_merge_request #{status_class}", ':disabled' => 'disableAcceptance' do Accept Merge Request - if @merge_request.force_remove_source_branch? .accept-control diff --git a/app/views/projects/merge_requests/widget/open/_approvals_body.html.haml b/app/views/projects/merge_requests/widget/open/_approvals_body.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..60d1bf166cc0f8952e2bc7d94a3a5f5c3d55e23f --- /dev/null +++ b/app/views/projects/merge_requests/widget/open/_approvals_body.html.haml @@ -0,0 +1 @@ +%approvals-body{ ':user-can-approve' => 'approvals.user_can_approve', ':user-has-approved' => 'approvals.user_has_approved', ':approved-by' => 'approvals.approved_by', ':approvals-left':'approvals.approvals_left', ':suggested-approvers' => 'approvals.suggested_approvers' } diff --git a/app/views/projects/merge_requests/widget/open/_approvals_footer.html.haml b/app/views/projects/merge_requests/widget/open/_approvals_footer.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..87c584127cdc357a15de6c5269c8656285ec636d --- /dev/null +++ b/app/views/projects/merge_requests/widget/open/_approvals_footer.html.haml @@ -0,0 +1 @@ +%approvals-footer{ 'pending-avatar-svg' => custom_icon('icon_dotted_circle'), 'checkmark-svg' => custom_icon('icon_checkmark'), ':user-can-approve' => 'approvals.user_can_approve', ':user-has-approved' => 'approvals.user_has_approved', ':approved-by' => 'approvals.approved_by', ':approvals-left':'approvals.approvals_left' } diff --git a/app/views/projects/merge_requests/widget/open/_approve.html.haml b/app/views/projects/merge_requests/widget/open/_approve.html.haml deleted file mode 100644 index c762c8597ba43e01d9fd4f282b7ed672455846a3..0000000000000000000000000000000000000000 --- a/app/views/projects/merge_requests/widget/open/_approve.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%div - %h4 - = render_require_section(@merge_request) - - if @merge_request.can_approve?(current_user) - .append-bottom-10 - = form_for [:approve, @project.namespace.becomes(Namespace), @project, @merge_request], method: :post do |f| - = f.submit "Approve Merge Request", class: "btn btn-primary approve-btn" diff --git a/app/views/projects/merge_requests/widget/open/_rebase.html.haml b/app/views/projects/merge_requests/widget/open/_rebase.html.haml index d6d804cb32a33ac7e142fb9ba0e87c63a3cbd8d4..e524f05a7c836e038303c6e4f8bbf48bde8cb274 100644 --- a/app/views/projects/merge_requests/widget/open/_rebase.html.haml +++ b/app/views/projects/merge_requests/widget/open/_rebase.html.haml @@ -1,14 +1,13 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('merge_request_widget/ci_bundle.js') + - if @merge_request.rebase_in_progress? || (defined?(rebase_in_progress) && rebase_in_progress) - %h4 + %h4.rebase-in-progress = icon("spinner spin") Rebase in progress… %p This merge request is in the process of being rebased. - :javascript - $(function() { - merge_request_widget.rebaseInProgress() - }); - elsif !can_push_branch?(@merge_request.source_project, @merge_request.source_branch) %h4 = icon("exclamation-triangle") @@ -26,12 +25,3 @@ Rebase onto #{@merge_request.target_branch} .accept-control Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged. - - :javascript - $('.rebase-mr-form').on('ajax:send', function() { - $('.rebase-mr-form :input').disable(); - }); - - $('.js-rebase-button').on('click', function() { - $('.js-rebase-button').html(" Rebase in progress"); - }); diff --git a/app/views/shared/icons/_icon_checkmark.svg b/app/views/shared/icons/_icon_checkmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..d01e24d4b68db82a0c707c32d937904d87d90ce2 --- /dev/null +++ b/app/views/shared/icons/_icon_checkmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/views/shared/icons/_icon_dotted_circle.svg b/app/views/shared/icons/_icon_dotted_circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..fee95e1cab5ccbbec957f2ea25f60bc47665ca3f --- /dev/null +++ b/app/views/shared/icons/_icon_dotted_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 183b546d17a75fb66ec5d9041f18b3067fd4cf86..a08b30174a922c5d2fae205927bfd92e2ecf5bcb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -101,6 +101,9 @@ class Application < Rails::Application config.assets.precompile << "profile/profile_bundle.js" config.assets.precompile << "protected_branches/protected_branches_bundle.js" config.assets.precompile << "diff_notes/diff_notes_bundle.js" + config.assets.precompile << "lib/vue_resource.js" + config.assets.precompile << "merge_request_widget/widget_bundle.js" + config.assets.precompile << "merge_request_widget/ci_bundle.js" config.assets.precompile << "issuable/issuable_bundle.js" config.assets.precompile << "merge_request_widget/ci_bundle.js" config.assets.precompile << "boards/boards_bundle.js" diff --git a/config/routes/project.rb b/config/routes/project.rb index 9a9ecae4fc8ae6f4740c2c55a43ba13646ddb957..b819d6c62ad0009f621a572e8ae408c04b0a873b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -106,7 +106,10 @@ post :toggle_subscription ## EE-specific - post :approve + get :approvals + post :approvals, action: :approve + delete :approvals, action: :unapprove + post :rebase ## EE-specific diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index eeaec2f3ec87ff53cb9056ddf4cdb499a13cac78..f6c101189d453edacbb51167e6aef388d3556696 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -329,19 +329,21 @@ Feature: Project Merge Requests And I click link "Close" Then I should see closed merge request "Bug NS-04" + @javascript Scenario: Developer can approve merge request Given I am a "Shop" developer And I visit project "Shop" merge requests page And merge request 'Bug NS-04' must be approved And I click link "Bug NS-04" - And I should not see merge button + And I should see the merge button disabled When I click link "Approve" Then I should see approved merge request "Bug NS-04" + @javascript Scenario: I can not approve merge request if I am not an approver Given merge request 'Bug NS-04' must be approved by some user And I click link "Bug NS-04" - And I should not see merge button + And I should see the merge button disabled When I should not see Approve button And I should see message that MR require an approval diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 1058697f0411c8e8704245a797f7ca0967479a36..6b528ea6beb98e325dd8f0698911d47ffad43680 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -604,13 +604,20 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click link "Approve"' do page.within '.mr-state-widget' do + wait_for_ajax click_button 'Approve Merge Request' end end + step 'I should see the merge button disabled' do + page.within '.mr-state-widget' do + expect(page).to have_button('Accept Merge Request', disabled: true) + end + end + step 'I should not see merge button' do page.within '.mr-state-widget' do - expect(page).not_to have_button("Accept Merge Request") + expect(page).not_to have_button('Accept Merge Request') end end @@ -622,7 +629,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see approved merge request "Bug NS-04"' do page.within '.mr-state-widget' do - expect(page).to have_button("Accept Merge Request") + expect(page).to have_button('Accept Merge Request', disabled: false) end end @@ -634,13 +641,13 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see message that MR require an approval from me' do page.within '.mr-state-widget' do - expect(page).to have_content("Requires one more approval (from #{current_user.name})") + expect(page).to have_content("Requires 1 more approval (from #{current_user.name})") end end step 'I should see message that MR require an approval' do page.within '.mr-state-widget' do - expect(page).to have_content("Requires one more approval") + expect(page).to have_content("Requires 1 more approval") end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 68f63fef422efde558526f7096da08e12fd4bb13..6505e7995ec0fff59047eb6c1a53a2aed54cf869 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -340,6 +340,15 @@ class MergeRequestApprovals < ProjectEntity expose :approvals_required expose :approvals_left expose :approvals, as: :approved_by, using: Entities::Approvals + expose :approvers_left, as: :suggested_approvers, using: Entities::UserBasic + + expose :user_has_approved do |merge_request, options| + merge_request.has_approved?(options[:current_user]) + end + + expose :user_can_approve do |merge_request, options| + merge_request.can_approve?(options[:current_user]) + end end class MergeRequestDiff < Grape::Entity diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index fb77691e714d7296c926e8a6ea0135001c80dd7e..bafb1e350c0e16f19e92cdf126d27ac6fb61640f 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -313,6 +313,18 @@ def handle_merge_request_errors!(errors) present merge_request, with: Entities::MergeRequestApprovals, current_user: current_user end + + delete "#{path}/unapprove" do + merge_request = user_project.merge_requests.find(params[:merge_request_id]) + + not_found! unless merge_request.has_approved?(current_user) + + ::MergeRequests::RemoveApprovalService + .new(user_project, current_user) + .execute(merge_request) + + present merge_request, with: Entities::MergeRequestApprovals, current_user: current_user + end end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 73242f7a2f19450b8ad39a605cbc52e1f7e2ee72..a20108ace92d4fea3fde395a05360107cddaa47a 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -125,6 +125,83 @@ def create_merge_request(overrides = {}) end end + context 'approvals' do + def json_response + JSON.load(response.body) + end + + let(:approver) { create(:user) } + + before do + merge_request.update_attribute :approvals_before_merge, 2 + project.team << [approver, :developer] + project.approver_ids = [user, approver].map(&:id).join(',') + end + + describe 'approve' do + before do + post :approve, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid, + format: :json + end + + it 'approves the merge request' do + expect(response).to be_success + expect(json_response['approvals_left']).to eq 1 + expect(json_response['approved_by'].size).to eq 1 + expect(json_response['approved_by'][0]['user']['username']).to eq user.username + expect(json_response['user_has_approved']).to be true + expect(json_response['user_can_approve']).to be false + expect(json_response['suggested_approvers'].size).to eq 1 + expect(json_response['suggested_approvers'][0]['username']).to eq approver.username + end + end + + describe 'approvals' do + before do + merge_request.approvals.create(user: approver) + get :approvals, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid, + format: :json + end + + it 'shows approval information' do + expect(response).to be_success + expect(json_response['approvals_left']).to eq 1 + expect(json_response['approved_by'].size).to eq 1 + expect(json_response['approved_by'][0]['user']['username']).to eq approver.username + expect(json_response['user_has_approved']).to be false + expect(json_response['user_can_approve']).to be true + expect(json_response['suggested_approvers'].size).to eq 1 + expect(json_response['suggested_approvers'][0]['username']).to eq user.username + end + end + + describe 'unapprove' do + before do + merge_request.approvals.create(user: user) + delete :unapprove, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid, + format: :json + end + + it 'unapproves the merge request' do + expect(response).to be_success + expect(json_response['approvals_left']).to eq 2 + expect(json_response['approved_by']).to be_empty + expect(json_response['user_has_approved']).to be false + expect(json_response['user_can_approve']).to be true + expect(json_response['suggested_approvers'].size).to eq 2 + end + end + end + shared_examples "loads labels" do |action| it "loads labels into the @labels variable" do get action, diff --git a/spec/features/merge_requests/approvals_spec.rb b/spec/features/merge_requests/approvals_spec.rb index d30eb17c5ccbffdf1045d17e86518bb20d86718b..cf87f0bacd2183115d28c697bd413d75e1f18245 100644 --- a/spec/features/merge_requests/approvals_spec.rb +++ b/spec/features/merge_requests/approvals_spec.rb @@ -75,7 +75,9 @@ find('.select2-results').click click_on("Submit merge request") - expect(page).to have_content("Requires one more approval (from #{other_user.name})") + + find('.approvals-components') + expect(page).to have_content("Requires 1 more approval (from #{other_user.name})") end it 'allows delete approvers group when it is set in project' do @@ -94,7 +96,10 @@ expect(page).to have_css('.approver-list li', count: 1) click_on("Submit merge request") - expect(page).not_to have_content("Requires one more approval (from #{other_user.name})") + + wait_for_ajax + find('.approvals-components') + expect(page).not_to have_content("Requires 1 more approval (from #{other_user.name})") end end @@ -123,7 +128,9 @@ find('.select2-results').click click_on("Save changes") - expect(page).to have_content("Requires one more approval") + wait_for_ajax + find('.approvals-components') + expect(page).to have_content("Requires 1 more approval") end it 'allows delete approvers group when it`s set in project' do @@ -142,7 +149,9 @@ expect(page).to have_css('.approver-list li', count: 1) click_on("Save changes") - expect(page).to have_content("Requires one more approval (from #{approver.name})") + + find('.approvals-components') + expect(page).to have_content("Requires 1 more approval (from #{approver.name})") end end end @@ -161,32 +170,57 @@ login_as(user) end - context 'when group is assigned to a project' do - it 'I am able to approve' do + context 'when group is assigned to a project', js: true do + before do create :approver_group, group: group, target: project - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - page.within '.mr-state-widget' do - click_button 'Approve Merge Request' - end + it 'I am able to approve' do + approve_merge_request + expect(page).to have_content('Approved by') + expect(page).to have_css('.approver-avatar') + end - expect(page).to have_content("Approved by") + it 'I am able to unapprove' do + approve_merge_request + unapprove_merge_request + expect(page).to have_no_css('.approver-avatar') end end - context 'when group is assigned to a merge request' do - it 'I am able to approve' do + context 'when group is assigned to a merge request', js: true do + before do create :approver_group, group: group, target: merge_request - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - page.within '.mr-state-widget' do - click_button 'Approve Merge Request' - end + it 'I am able to approve' do + approve_merge_request + wait_for_ajax + expect(page).to have_content('Approved by') + expect(page).to have_css('.approver-avatar') + end - expect(page).to have_content("Approved by") + it 'I am able to unapprove' do + approve_merge_request + unapprove_merge_request + expect(page).to have_no_css('.approver-avatar') end end end end + +def approve_merge_request + page.within '.mr-state-widget' do + click_button 'Approve Merge Request' + end + wait_for_ajax +end + +def unapprove_merge_request + page.within '.mr-state-widget' do + find('.unapprove-btn-wrap').click + end + wait_for_ajax +end diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index aa24a9050016ca096232142f9d708346b5101ed4..04441d1fbea4ee2ce53bc58fe90d77ba3c21b61e 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -32,7 +32,7 @@ expect(page).to have_button "Merge When Pipeline Succeeds" end - context "Merge When Pipeline Succeeds enabled" do + context "Merge When Pipeline Succeeds enabled", js: true do before do click_button "Merge When Pipeline Succeeds" end diff --git a/spec/javascripts/approvals/approvals_body_spec.js.es6 b/spec/javascripts/approvals/approvals_body_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..c3baa5d274cf1a1ceadde99ab523ac32931afea7 --- /dev/null +++ b/spec/javascripts/approvals/approvals_body_spec.js.es6 @@ -0,0 +1,131 @@ +/* global Vue eslint-disable fixture */ +//= require vue +//= require underscore +//= require jquery +//= require merge_request_widget/approvals/components/approvals_body + +(() => { + gl.ApprovalsStore = { + data: {}, + initStoreOnce() { + return { + then() {}, + }; + }, + }; + + function initApprovalsBodyComponent() { + fixture.set(` +
+
+
+ `); + + this.initialData = { + suggestedApprovers: [{ name: 'Approver 1' }], + userCanApprove: false, + userHasApproved: true, + approvedBy: [], + approvalsLeft: 1, + pendingAvatarSvg: '', + checkmarkSvg: '', + }; + + const ApprovalsBodyComponent = Vue.component('approvals-body'); + + this.approvalsBody = new ApprovalsBodyComponent({ + el: '#mock-container', + propsData: this.initialData, + }); + } + + describe('Approvals Body Component', function () { + beforeEach(function () { + initApprovalsBodyComponent.call(this); + }); + + it('should correctly set component props', function () { + const approvalsBody = this.approvalsBody; + _.each(approvalsBody, (propValue, propKey) => { + if (this.initialData[propKey]) { + expect(approvalsBody[propKey]).toBe(this.initialData[propKey]); + } + }); + }); + + describe('Computed properties', function () { + describe('approvalsRequiredStringified', function () { + it('should display the correct string for 1 possible approver', function () { + const correctText = '1 more approval'; + expect(this.approvalsBody.approvalsRequiredStringified).toBe(correctText); + }); + + it('should display the correct string for 2 possible approver', function (done) { + this.approvalsBody.approvalsLeft = 2; + this.approvalsBody.suggestedApprovers.push({ name: 'Approver 2' }); + + Vue.nextTick(() => { + const correctText = '2 more approvals'; + expect(this.approvalsBody.approvalsRequiredStringified).toBe(correctText); + done(); + }); + }); + }); + + describe('approverNamesStringified', function () { + // Preceded by: Requires {1 more approval} required from _____ + it('should display the correct string for 1 possible approver name', function (done) { + const correctText = 'Approver 1'; + Vue.nextTick(() => { + expect(this.approvalsBody.approverNamesStringified).toBe(correctText); + done(); + }); + }); + + it('should display the correct string for 2 possible approver names', function (done) { + this.approvalsBody.approvalsLeft = 2; + this.approvalsBody.suggestedApprovers.push({ name: 'Approver 2' }); + + Vue.nextTick(() => { + const correctText = 'Approver 1 or Approver 2'; + expect(this.approvalsBody.approverNamesStringified).toBe(correctText); + done(); + }); + }); + + it('should display the correct string for 3 possible approver names', function (done) { + this.approvalsBody.approvalsLeft = 3; + this.approvalsBody.suggestedApprovers.push({ name: 'Approver 2' }); + this.approvalsBody.suggestedApprovers.push({ name: 'Approver 3' }); + + Vue.nextTick(() => { + const correctText = 'Approver 1, Approver 2 or Approver 3'; + expect(this.approvalsBody.approverNamesStringified).toBe(correctText); + done(); + }); + }); + }); + + describe('showApproveButton', function () { + it('should not be true when the user cannot approve', function (done) { + this.approvalsBody.userCanApprove = false; + this.approvalsBody.userHasApproved = true; + Vue.nextTick(() => { + expect(this.approvalsBody.showApproveButton).toBe(false); + done(); + }); + }); + + it('should be true when the user can approve', function (done) { + this.approvalsBody.userCanApprove = true; + this.approvalsBody.userHasApproved = false; + Vue.nextTick(() => { + expect(this.approvalsBody.showApproveButton).toBe(true); + done(); + }); + }); + }); + }); + }); +})(window.gl || (window.gl = {})); + diff --git a/spec/javascripts/approvals/approvals_footer_spec.js.es6 b/spec/javascripts/approvals/approvals_footer_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..c6f98cbd25d989b430912f00d815e87696508ff5 --- /dev/null +++ b/spec/javascripts/approvals/approvals_footer_spec.js.es6 @@ -0,0 +1,72 @@ +/* global Vue eslint-disable fixture */ +//= require vue +//= require underscore +//= require jquery +//= require merge_request_widget/approvals/components/approvals_footer + +(() => { + gl.ApprovalsStore = { + data: {}, + initStoreOnce() { + return { + then() {}, + }; + }, + }; + + function initApprovalsFooterComponent() { + fixture.set(` +
+
+
+ `); + + this.initialData = { + userCanApprove: false, + userHasApproved: true, + approvedBy: [], + approvalsLeft: 1, + pendingAvatarSvg: '', + checkmarkSvg: '', + }; + + const ApprovalsFooterComponent = Vue.component('approvals-footer'); + + this.approvalsFooter = new ApprovalsFooterComponent({ + el: '#mock-container', + propsData: this.initialData, + beforeCreate() {}, + }); + } + + describe('Approvals Footer Component', function () { + beforeEach(function () { + initApprovalsFooterComponent.call(this); + }); + + it('should correctly set component props', function () { + const approvalsFooter = this.approvalsFooter; + _.each(approvalsFooter, (propValue, propKey) => { + if (this.initialData[propKey]) { + expect(approvalsFooter[propKey]).toBe(this.initialData[propKey]); + } + }); + }); + + describe('Computed properties', function () { + it('should correctly set showUnapproveButton when the user can unapprove', function () { + expect(this.approvalsFooter.showUnapproveButton).toBe(true); + }); + + it('should correctly set showUnapproveButton when the user can not unapprove', function (done) { + this.approvalsFooter.userCanApprove = true; + + Vue.nextTick(() => { + expect(this.approvalsFooter.showUnapproveButton).toBe(false); + done(); + }); + }); + }); + }); +})(window.gl || (window.gl = {})); + diff --git a/spec/javascripts/approvals/approvals_store_spec.js.es6 b/spec/javascripts/approvals/approvals_store_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..585a88b65dbb63a2b7aa26102e4d97fa1b5a894b --- /dev/null +++ b/spec/javascripts/approvals/approvals_store_spec.js.es6 @@ -0,0 +1,67 @@ +//= require es6-promise.auto +//= require jquery +//= require vue +//= require vue-resource +//= require merge_request_widget/approvals/approvals_store + +$.rails = { + csrfToken() {}, +}; + +(() => { + // stand in for promise returned by api calls + const mockThenable = { + then() { + return { + catch() {}, + }; + }, + catch() {}, + }; + + const mockRootStore = { + data: {}, + rootEl: { + dataset: { + endpoint: 'gitlab/myendpoint/', + }, + }, + assignToData(key, val) { + return { key, val }; + }, + }; + + describe('Approvals Store', function () { + beforeEach(function () { + this.rootStore = mockRootStore; + this.approvalsStore = new gl.MergeRequestApprovalsStore(this.rootStore); + }); + + it('should define all needed approval api calls', function () { + expect(this.approvalsStore.fetch).toBeDefined(); + expect(this.approvalsStore.approve).toBeDefined(); + expect(this.approvalsStore.unapprove).toBeDefined(); + }); + + it('should only init the store once', function () { + spyOn(this.approvalsStore, 'fetch').and.callFake(() => mockThenable); + + this.approvalsStore.initStoreOnce(); + this.approvalsStore.initStoreOnce(); + this.approvalsStore.initStoreOnce(); + + expect(this.approvalsStore.fetch.calls.count()).toBe(1); + }); + + it('should be able to write to the rootStore', function () { + const dataToStore = { myMockData: 'string' }; + + spyOn(this.rootStore, 'assignToData'); + + this.approvalsStore.assignToRootStore('approvals', dataToStore); + + expect(this.rootStore.assignToData).toHaveBeenCalled(); + expect(this.rootStore.assignToData).toHaveBeenCalledWith('approvals', dataToStore); + }); + }); +})(window.gl || (window.gl = {})); diff --git a/spec/javascripts/labels_issue_sidebar_spec.js.es6 b/spec/javascripts/labels_issue_sidebar_spec.js.es6 index e3146559a4a44f1bf4f636091d8d8a3761ce98ba..c79992dd9293ad1b5932aae3e572e7130cc5d8bd 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js.es6 +++ b/spec/javascripts/labels_issue_sidebar_spec.js.es6 @@ -1,4 +1,4 @@ -/* eslint-disable no-new, no-plusplus, object-curly-spacing, prefer-const, semi */ +/* eslint-disable no-new, no-plusplus, object-curly-spacing, prefer-const, semi, new-cap */ /* global IssuableContext */ /* global LabelsSelect */ diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget/merge_request_widget_spec.js similarity index 100% rename from spec/javascripts/merge_request_widget_spec.js rename to spec/javascripts/merge_request_widget/merge_request_widget_spec.js diff --git a/spec/javascripts/vue_common_components/link_to_member_avatar_spec.js.es6 b/spec/javascripts/vue_common_components/link_to_member_avatar_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..55f6f23a0e69bf0d88ead6d443972615dbb5588b --- /dev/null +++ b/spec/javascripts/vue_common_components/link_to_member_avatar_spec.js.es6 @@ -0,0 +1,74 @@ +/* eslint-disable */ +//= require vue +//= require jquery +//= require vue_common_component/link_to_member_avatar + +((gl) => { + function initComponent(propsData = {}) { + fixture.set(` +
+
+
+ `); + + const LinkToMembersComponent = Vue.component('link-to-member-avatar'); + + this.component = new LinkToMembersComponent({ + el: '#mock-container', + propsData, + }).$mount(); + + this.$document = $(document); + } + describe('Link To Members Components', function() { + describe('Initialization', function() { + beforeEach(function() { + const propsData = this.propsData = { + avatarSize: 32, + avatarUrl: 'myavatarurl.com', + displayName: 'mydisplayname', + extraAvatarClass: 'myextraavatarclass', + extraLinkClass: 'myextralinkclass', + showTooltip: true, + }; + initComponent.call(this, { + propsData + }); + }); + + it('should return a defined Vue component', function() { + expect(this.component).toBeDefined(); + expect(this.component.$data).toBeDefined(); + }); + + it('should have and children', function() { + const componentLink = this.component.$el.querySelector('a'); + const componentImg = componentLink.querySelector('img'); + + expect(componentLink).not.toBeNull(); + expect(componentImg).not.toBeNull(); + }); + + it('should correctly compute computed values', function(done) { + const correctVals = { + disabledClass: '', + avatarSizeClass: 's32', + avatarHtmlClass: 's32 avatar avatar-inline', + avatarClass: 'avatar avatar-inline s32 ', + tooltipClass: 'has-tooltip', + linkClass: 'author_link has-tooltip ', + tooltipContainerAttr: 'body', + }; + + Vue.nextTick(() => { + for (var computedKey in correctVals) { + const expectedVal = correctVals[computedKey]; + const actualComputed = this.component[computedKey]; + expect(actualComputed).toBe(expectedVal); + } + done(); + }); + }); + }); + }); +})(window.gl || (window.gl = {})); diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index feeaa987105eb917a959cd1e1579011a211b6c52..76bc2d0685cc4c49405f0373caabc99deafb4b15 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -405,6 +405,44 @@ end end + describe 'that are unapproved' do + let(:last_unapprover) { create(:user) } + subject { Notify.unapproved_merge_request_email(recipient.id, merge_request.id, last_unapprover.id) } + + before do + merge_request.approvals.create(user: merge_request.assignee) + end + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + + it 'is sent as the last unapprover' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(last_unapprover.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject' do + is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + end + + it 'contains the new status' do + is_expected.to have_body_text /unapproved/i + end + + it 'contains a link to the merge request' do + is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + end + + it 'contains the names of all of the approvers' do + is_expected.to have_body_text /#{merge_request.assignee.name}/ + end + end + describe 'that are merged' do subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index a65cac088f6524a7d760cb4fde7737daf5d9ef82..352644ab7a53317d0d53c894475219864dd2f17a 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -745,6 +745,8 @@ def create_merge_request(approvals_before_merge) expect(json_response['approvals_required']).to eq 2 expect(json_response['approvals_left']).to eq 1 expect(json_response['approved_by'][0]['user']['username']).to eq(approver.username) + expect(json_response['user_can_approve']).to be false + expect(json_response['user_has_approved']).to be false end end @@ -773,6 +775,36 @@ def create_merge_request(approvals_before_merge) expect(response.status).to eq(201) expect(json_response['approvals_left']).to eq(1) expect(json_response['approved_by'][0]['user']['username']).to eq(approver.username) + expect(json_response['user_has_approved']).to be true + end + end + end + + describe 'DELETE :id/merge_requests/:merge_request_id/unapprove' do + before { project.update_attribute(:approvals_before_merge, 2) } + + context 'as a user who has approved the merge request' do + let(:approver) { create(:user) } + let(:unapprover) { create(:user) } + + before do + project.team << [approver, :developer] + project.team << [unapprover, :developer] + project.team << [create(:user), :developer] + merge_request.approvals.create(user: approver) + merge_request.approvals.create(user: unapprover) + + delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unapprove", unapprover) + end + + it 'unapproves the merge request' do + expect(response.status).to eq(200) + expect(json_response['approvals_left']).to eq(1) + usernames = json_response['approved_by'].map { |u| u['user']['username'] } + expect(usernames).not_to include(unapprover.username) + expect(usernames.size).to be 1 + expect(json_response['user_has_approved']).to be false + expect(json_response['user_can_approve']).to be true end end end diff --git a/spec/services/merge_requests/remove_approval_service_spec.rb b/spec/services/merge_requests/remove_approval_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebfd1ed603da5cf74f2c661772c73a81032bf4d9 --- /dev/null +++ b/spec/services/merge_requests/remove_approval_service_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +describe MergeRequests::RemoveApprovalService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + + subject(:service) { described_class.new(project, user) } + + def execute! + service.execute(merge_request) + end + + context 'with a user who has approved' do + before do + merge_request.approvals.create(user: user) + end + + it 'removes the approval' do + expect(merge_request.approvals.size).to eq 1 + execute! + expect(merge_request.approvals).to be_empty + end + + it 'creates an unapproval note' do + expect(SystemNoteService).to receive(:unapprove_mr) + + execute! + end + + it 'does not send a notification' do + expect(Notify).not_to receive(:unapprove_mr) + + execute! + end + end + + context 'with an approved merge request' do + let(:notify) { Object.new } + + before do + merge_request.update_attribute :approvals_before_merge, 1 + merge_request.approvals.create(user: user) + allow(service).to receive(:notification_service).and_return(notify) + end + + it 'sends a notification' do + expect(notify).to receive(:unapprove_mr) + execute! + end + end + end +end