summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorJacob Schatz <jschatz@gitlab.com>2017-01-18 20:23:53 +0000
committerJacob Schatz <jschatz@gitlab.com>2017-01-18 20:23:53 +0000
commite808af8c95ba1173f87546783f00b94e87b456ea (patch)
tree211b0da231ae8a0034076d2f0b842f1a76217f53 /app
parent745f1f8d1024fea32e091269cf1f65337cb12c66 (diff)
parent3faffa180df0b8e488a94591935fbbb39c4ed7ad (diff)
downloadgitlab-ce-e808af8c95ba1173f87546783f00b94e87b456ea.tar.gz
Merge branch 'approval-integration' into 'master'
MR Approvals Frontend-Backend Integration Closes #1262, #894, and #1287 See merge request !954
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/mailers/approval/icon-merge-request-gray.gifbin0 -> 650 bytes
-rw-r--r--app/assets/images/mailers/approval/icon-x-orange-inverted.gifbin0 -> 255 bytes
-rw-r--r--app/assets/javascripts/generic_bundles/vue_resource.js.es62
-rw-r--r--app/assets/javascripts/merge_request_widget/approvals/approvals_api.js.es636
-rw-r--r--app/assets/javascripts/merge_request_widget/approvals/approvals_bundle.js.es63
-rw-r--r--app/assets/javascripts/merge_request_widget/approvals/approvals_store.js.es665
-rw-r--r--app/assets/javascripts/merge_request_widget/approvals/components/approvals_body.js.es694
-rw-r--r--app/assets/javascripts/merge_request_widget/approvals/components/approvals_footer.js.es693
-rw-r--r--app/assets/javascripts/merge_request_widget/widget_bundle.js.es615
-rw-r--r--app/assets/javascripts/merge_request_widget/widget_store.js.es640
-rw-r--r--app/assets/javascripts/vue_common_component/link_to_member_avatar.js.es692
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss90
-rw-r--r--app/controllers/projects/merge_requests_controller.rb30
-rw-r--r--app/helpers/merge_requests_helper.rb1
-rw-r--r--app/mailers/emails/merge_requests.rb13
-rw-r--r--app/models/concerns/approvable.rb6
-rw-r--r--app/services/merge_requests/remove_approval_service.rb31
-rw-r--r--app/services/notification_service.rb12
-rw-r--r--app/services/system_note_service.rb5
-rw-r--r--app/views/notify/approved_merge_request_email.html.haml148
-rw-r--r--app/views/notify/approved_merge_request_email.text.haml2
-rw-r--r--app/views/notify/unapproved_merge_request_email.html.haml146
-rw-r--r--app/views/notify/unapproved_merge_request_email.text.haml8
-rw-r--r--app/views/projects/merge_requests/_show.html.haml8
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml23
-rw-r--r--app/views/projects/merge_requests/widget/open/_accept.html.haml2
-rw-r--r--app/views/projects/merge_requests/widget/open/_approvals_body.html.haml1
-rw-r--r--app/views/projects/merge_requests/widget/open/_approvals_footer.html.haml1
-rw-r--r--app/views/projects/merge_requests/widget/open/_approve.html.haml7
-rw-r--r--app/views/projects/merge_requests/widget/open/_rebase.html.haml18
-rw-r--r--app/views/shared/icons/_icon_checkmark.svg1
-rw-r--r--app/views/shared/icons/_icon_dotted_circle.svg1
32 files changed, 951 insertions, 43 deletions
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 00000000000..30cbe66980f
--- /dev/null
+++ b/app/assets/images/mailers/approval/icon-merge-request-gray.gif
Binary files 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 00000000000..7fbf1c41384
--- /dev/null
+++ b/app/assets/images/mailers/approval/icon-x-orange-inverted.gif
Binary files 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 00000000000..eff1dcabfa2
--- /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 00000000000..4f181e02f39
--- /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 00000000000..e5173098f93
--- /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 00000000000..1c96109656b
--- /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 00000000000..7451639de35
--- /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: `
+ <div class='approvals-body mr-widget-footer'>
+ <h4> Requires {{ approvalsRequiredStringified }}
+ <span v-if='showSuggestedApprovers'> (from {{ approverNamesStringified }}) </span>
+ </h4>
+ <div v-if='showApproveButton' class='append-bottom-10'>
+ <button
+ :disabled='approving'
+ @click='approveMergeRequest'
+ class='btn btn-primary approve-btn'>
+ Approve Merge Request
+ </button>
+ </div>
+ </div>
+ `,
+ });
+})();
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 00000000000..dfa9c62e344
--- /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: `
+ <div class='mr-widget-footer approved-by-users approvals-footer clearfix'>
+ <span class='approvers-prefix'> Approved by </span>
+ <span v-for='approver in approvedBy'>
+ <link-to-member-avatar
+ extra-link-class='approver-avatar'
+ :avatar-url='approver.user.avatar_url'
+ :display-name='approver.user.name'
+ :profile-url='approver.user.web_url'
+ :avatar-html='checkmarkSvg'
+ :show-tooltip='true'>
+ </link-to-member-avatar>
+ </span>
+ <span v-for='n in approvalsLeft'>
+ <link-to-member-avatar
+ :clickable='false'
+ :avatar-html='pendingAvatarSvg'
+ :show-tooltip='false'
+ extra-link-class='hide-asset'>
+ </link-to-member-avatar>
+ </span>
+ <span class='unapprove-btn-wrap' v-if='showUnapproveButton'>
+ <button
+ :disabled='unapproving'
+ @click='unapproveMergeRequest'
+ class='btn btn-link unapprove-btn'>
+ <i class='fa fa-close'></i>
+ Remove your approval</span>
+ </button>
+ </span>
+ </div>
+ `,
+ });
+})();
+
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 00000000000..0986ae242ec
--- /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 00000000000..a934712f5b9
--- /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 00000000000..f36591c6181
--- /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: `
+ <div class='link-to-member-avatar'>
+ <a :href='profileUrl' :class='linkClass' :data-original-title='displayName' :data-container='tooltipContainerAttr'>
+ <svg v-if='avatarHtml' v-html='avatarHtml' :class='avatarHtmlClass' :width='avatarSize' :height='avatarSize' :alt='displayName'></svg>
+ <img :class='avatarClass' :src='avatarUrl' :width='avatarSize' :height='avatarSize' :alt='displayName'/>
+ </a>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 45ff9f7ff5f..e6b66036f94 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 44cd2466d48..0d0f81f93ce 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 @@ class Projects::MergeRequestsController < Projects::ApplicationController
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 759d85fdb31..568dabb1e4b 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -90,6 +90,7 @@ module MergeRequestsHelper
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 5bd8c37ed7b..73dea209b88 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -54,11 +54,18 @@ module Emails
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 561a3839276..a34eac68f23 100644
--- a/app/models/concerns/approvable.rb
+++ b/app/models/concerns/approvable.rb
@@ -123,6 +123,12 @@ module Approvable
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 00000000000..8ae6651e59b
--- /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 89396156d0c..34982ff4167 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -162,6 +162,10 @@ class NotificationService
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 @@ class NotificationService
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 c6c9d006809..9e6a3c8f31d 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -474,6 +474,11 @@ module SystemNoteService
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 de2c3818494..16b9df65f5e 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}"
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+%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;" }
+ &nbsp;
+ %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;" }
+ &nbsp;
+ %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
+ &middot;
+ %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 5038a70e386..1b7af953b10 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 00000000000..b7c5e9a89a9
--- /dev/null
+++ b/app/views/notify/unapproved_merge_request_email.html.haml
@@ -0,0 +1,146 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+%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;" }
+ &nbsp;
+ %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;" }
+ &nbsp;
+ %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
+ &middot;
+ %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 00000000000..7e31b2f45e6
--- /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 0516801cdf3..24ca7bc3fd1 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 bfc096cfe63..a2127549a90 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 81125aa0dac..5daeb6da6fd 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 00000000000..60d1bf166cc
--- /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 00000000000..87c584127cd
--- /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 c762c8597ba..00000000000
--- 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 d6d804cb32a..e524f05a7c8 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&hellip;
%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("<i class='fa fa-spinner fa-spin'></i> 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 00000000000..d01e24d4b68
--- /dev/null
+++ b/app/views/shared/icons/_icon_checkmark.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="0" d="m5.743 5.743h-2c-.556 0-1 .448-1 1 0 .556.448 1 1 1h3c.556 0 1-.448 1-1v-5.01c0-.54-.448-.991-1-.991-.556 0-1 .444-1 .991v4.01"/><mask id="1" width="6" height="8" x="-.5" y="-.5"><path fill="#fff" d="m2.243.243h6v8h-6z"/><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd" transform="matrix(.70711.70711-.70711.70711 4.536-2.465)"><use fill="#31af64" xlink:href="#0"/><use stroke="#fff" mask="url(#1)" xlink:href="#0"/></g></svg> \ 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 00000000000..fee95e1cab5
--- /dev/null
+++ b/app/views/shared/icons/_icon_dotted_circle.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"><path fill="#bfbfbf" fill-rule="evenodd" d="m13.5 26.5c1.412 0 2.794-.225 4.107-.662l-.316-.949c-1.212.403-2.487.611-3.792.611v1m6.06-1.495c1.234-.651 2.355-1.498 3.321-2.504l-.721-.692c-.892.929-1.928 1.711-3.067 2.312l.467.884m4.66-4.147c.79-1.149 1.391-2.418 1.777-3.762l-.961-.276c-.356 1.24-.911 2.411-1.64 3.471l.824.567m2.184-5.761c.063-.518.096-1.041.097-1.568 0-.896-.085-1.758-.255-2.603l-.98.197c.157.78.236 1.576.236 2.405-.001.486-.031.97-.09 1.448l.993.122m-.738-6.189c-.493-1.307-1.195-2.523-2.075-3.605l-.776.631c.812.999 1.46 2.122 1.916 3.327l.935-.353m-3.539-5.133c-1.043-.926-2.229-1.68-3.512-2.229l-.394.919c1.184.507 2.279 1.203 3.242 2.058l.664-.748m-5.463-2.886c-1.012-.253-2.058-.384-3.119-.388-.378 0-.717.013-1.059.039l.077.997c.316-.024.629-.036.98-.036.979.003 1.944.124 2.879.358l.243-.97m-6.238-.022c-1.361.33-2.653.878-3.832 1.619l.532.847c1.089-.684 2.281-1.189 3.536-1.494l-.236-.972m-5.517 2.878c-1.047.922-1.94 2.01-2.643 3.212l.864.504c.649-1.112 1.474-2.114 2.441-2.966l-.661-.75m-3.54 5.076c-.499 1.293-.789 2.664-.854 4.072l.999.046c.06-1.3.328-2.564.788-3.758l-.933-.36m-.78 6.202c.163 1.396.549 2.744 1.14 4l.905-.425c-.545-1.16-.902-2.404-1.052-3.692l-.993.116m2.177 5.814c.788 1.151 1.756 2.169 2.866 3.01l.606-.796c-1.025-.78-1.919-1.721-2.646-2.783l-.825.565m4.665 4.164c1.23.65 2.559 1.1 3.943 1.328l.162-.987c-1.278-.21-2.503-.625-3.638-1.225l-.468.884m6.02 1.501c.024 0 .024 0 .048 0v-1c-.022 0-.022 0-.044 0l-.004 1"/></svg> \ No newline at end of file