Commit 6d1d3907 authored by Valery Sizov's avatar Valery Sizov

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee into ce_upstream

parents ef0b3a56 a6d35c27
...@@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({ ...@@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data); this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
} else { } else {
new Flash(errorFlashMsg); new Flash(errorFlashMsg);
} }
......
...@@ -49,6 +49,7 @@ class ResolveServiceClass { ...@@ -49,6 +49,7 @@ class ResolveServiceClass {
discussion.resolveAllNotes(resolved_by); discussion.resolveAllNotes(resolved_by);
} }
gl.mrWidget.checkStatus();
discussion.updateHeadline(data); discussion.updateHeadline(data);
} else { } else {
throw new Error('An error occurred when trying to resolve discussion.'); throw new Error('An error occurred when trying to resolve discussion.');
......
...@@ -10,7 +10,6 @@ ...@@ -10,7 +10,6 @@
/* global IssuableForm */ /* global IssuableForm */
/* global LabelsSelect */ /* global LabelsSelect */
/* global MilestoneSelect */ /* global MilestoneSelect */
/* global MergedButtons */
/* global Commit */ /* global Commit */
/* global NotificationsForm */ /* global NotificationsForm */
/* global TreeView */ /* global TreeView */
...@@ -219,15 +218,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -219,15 +218,10 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.Diff(); new gl.Diff();
shortcut_handler = new ShortcutsIssuable(true); shortcut_handler = new ShortcutsIssuable(true);
new ZenMode(); new ZenMode();
new MergedButtons();
break;
case 'projects:merge_requests:commits':
new MergedButtons();
break; break;
case "projects:merge_requests:diffs": case "projects:merge_requests:diffs":
new gl.Diff(); new gl.Diff();
new ZenMode(); new ZenMode();
new MergedButtons();
break; break;
case 'dashboard:activity': case 'dashboard:activity':
new gl.Activities(); new gl.Activities();
......
import Vue from 'vue';
import VueResource from 'vue-resource';
require('./components/time_tracker');
require('../../smart_interval');
require('../../subbable_resource');
Vue.use(VueResource);
(() => {
/* This Vue instance represents what will become the parent instance for the
* sidebar. It will be responsible for managing `issuable` state and propagating
* changes to sidebar components. We will want to create a separate service to
* interface with the server at that point.
*/
class IssuableTimeTracking {
constructor(issuableJSON) {
const parsedIssuable = JSON.parse(issuableJSON);
return this.initComponent(parsedIssuable);
}
initComponent(parsedIssuable) {
this.parentInstance = new Vue({
el: '#issuable-time-tracker',
data: {
issuable: parsedIssuable,
},
methods: {
fetchIssuable() {
return gl.IssuableResource.get.call(gl.IssuableResource, {
type: 'GET',
url: `${gl.IssuableResource.endpoint}?basic=true`,
});
},
updateState(data) {
this.issuable = data;
},
subscribeToUpdates() {
gl.IssuableResource.subscribe(data => this.updateState(data));
},
listenForSlashCommands() {
$(document).on('ajax:success', '.gfm-form', (e, data) => {
const subscribedCommands = ['spend_time', 'time_estimate'];
const changedCommands = data.commands_changes
? Object.keys(data.commands_changes)
: [];
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
this.fetchIssuable();
}
});
},
},
created() {
this.fetchIssuable();
},
mounted() {
this.subscribeToUpdates();
this.listenForSlashCommands();
},
});
}
}
gl.IssuableTimeTracking = IssuableTimeTracking;
})(window.gl || (window.gl = {}));
export default (fn, interval = 2000, timeout = 60000) => {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
const next = () => {
if (Date.now() - startTime < timeout) {
setTimeout(fn.bind(null, next, stop), interval);
} else {
reject(new Error('SIMPLE_POLL_TIMEOUT'));
}
};
fn(next, stop);
});
};
...@@ -123,8 +123,6 @@ import './member_expiration_date'; ...@@ -123,8 +123,6 @@ import './member_expiration_date';
import './members'; import './members';
import './merge_request'; import './merge_request';
import './merge_request_tabs'; import './merge_request_tabs';
import './merge_request_widget';
import './merged_buttons';
import './milestone'; import './milestone';
import './milestone_select'; import './milestone_select';
import './mini_pipeline_graph_dropdown'; import './mini_pipeline_graph_dropdown';
......
...@@ -106,6 +106,21 @@ require('./merge_request_tabs'); ...@@ -106,6 +106,21 @@ require('./merge_request_tabs');
}); });
}; };
MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
$('.detail-page-header .status-box')
.removeClass(classToRemove)
.addClass(classToAdd)
.find('span')
.text(newStatusText);
};
MergeRequest.prototype.decreaseCounter = function(by = 1) {
const $el = $('.nav-links .js-merge-counter');
const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
$el.text(gl.text.addDelimiter(count));
};
return MergeRequest; return MergeRequest;
})(); })();
}).call(window); }).call(window);
/* global Flash */
import Vue from 'vue';
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));
}
}
window.gl = window.gl || {};
window.gl.ApprovalsApi = ApprovalsApi;
require('./approvals_store');
require('./approvals_api');
require('./components/approvals_body');
require('./components/approvals_footer');
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.json()))
.then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left));
}
approve() {
return this.api.approveMergeRequest()
.then(res => this.assignToRootStore('approvals', res.json()))
.then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left));
}
unapprove() {
return this.api.unapproveMergeRequest()
.then(res => this.assignToRootStore('approvals', res.json()))
.then(data => this.setMergeRequestAcceptanceStatus(data.approvals_left));
}
setMergeRequestAcceptanceStatus(approvalsLeft) {
return this.rootStore.assignToData('disableAcceptance', !!approvalsLeft);
}
assignToRootStore(key, data) {
return this.rootStore.assignToData(key, data);
}
}
window.gl = window.gl || {};
window.gl.MergeRequestApprovalsStore = MergeRequestApprovalsStore;
import Vue from 'vue';
import '../approvals_store';
import linkToMemberAvatar from '../../../vue_shared/components/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,
},
},
components: {
'link-to-member-avatar': linkToMemberAvatar,
},
data() {
return {
unapproving: false,
};
},
computed: {
showUnapproveButton() {
return this.userHasApproved && !this.userCanApprove;
},
},
methods: {
unapproveMergeRequest() {
this.unapproving = true;
gl.ApprovalsStore.unapprove().then(() => {
this.unapproving = false;
}).catch(() => {
this.unapproving = false;
});
},
},
beforeCreate() {
gl.ApprovalsStore.initStoreOnce();
},
template: `
<div class="mr-widget-footer approved-by-users approvals-footer clearfix mr-approvals-footer">
<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" />
</span>
<span v-for="n in approvalsLeft">
<link-to-member-avatar
:clickable="false"
:avatar-html="pendingAvatarSvg"
:show-tooltip="false"
extra-link-class="hide-asset" />
</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"
aria-hidden="true"/>
Remove your approval
</button>
</span>
</div>
`,
});
/* global merge_request_widget */
(() => {
$(() => {
/* TODO: This needs a better home, or should be refactored. It was previously contained
* in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml,
* but Vue chokes on script tags and prevents their execution. So it was moved here
* temporarily.
* */
$(document)
.off('ajax:send', '.accept-mr-form')
.on('ajax:send', '.accept-mr-form', () => {
$('.accept-mr-form :input').disable();
});
$(document)
.off('click', '.accept-merge-request')
.on('click', '.accept-merge-request', () => {
$('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress');
});
$(document)
.off('click', '.merge-when-pipeline-succeeds')
.on('click', '.merge-when-pipeline-succeeds', () => {
$('#merge_when_pipeline_succeeds').val('1');
});
$(document)
.off('click', '.js-merge-dropdown a')
.on('click', '.js-merge-dropdown a', (e) => {
e.preventDefault();
$(e.target).closest('form').submit();
});
if ($('.rebase-in-progress').length) {
merge_request_widget.rebaseInProgress();
} else if ($('.rebase-mr-form').length) {
$(document)
.off('ajax:send', '.rebase-mr-form')
.on('ajax:send', '.rebase-mr-form', () => {
$('.rebase-mr-form :input').disable();
});
$(document)
.off('click', '.js-rebase-button')
.on('click', '.js-rebase-button', () => {
$('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress");
});
} else {
// getMergeStatus replaces the MR widget with new, updated HTML, which means any persistent
// event management gets clobbered. When the MR is approvable, MR status is already managed
// and breaks when the DOM is clobbered.
setTimeout(() => merge_request_widget.getMergeStatus(), 200);
}
});
})();
/* global merge_request_widget */
import Vue from 'vue';
require('./widget_store');
require('./approvals/approvals_bundle');
window.gl = window.gl || {};
$(() => {
let widgetSharedStore;
gl.compileApprovalsWidget = () => {
const rootEl = document.getElementById('merge-request-widget-app');
if (gl.MergeRequestWidgetApp) {
gl.MergeRequestWidgetApp.$destroy();
} else {
widgetSharedStore = new gl.MergeRequestWidgetStore(rootEl);
}
gl.MergeRequestWidgetApp = new Vue({
el: rootEl,
data: widgetSharedStore.data,
});
};
gl.compileApprovalsWidget();
});
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', this.rootEl.dataset.approvalPending === 'true');
}
initApprovals() {
gl.ApprovalsStore = new gl.MergeRequestApprovalsStore(this);
this.assignToData('approvals', {});
}
assignToData(key, val) {
this.data[key] = val;
return val;
}
}
window.gl = window.gl || {};
window.gl.MergeRequestWidgetStore = MergeRequestWidgetStore;
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */
import '~/lib/utils/url_utility';
(function() {
this.MergedButtons = (function() {
function MergedButtons() {
this.removeSourceBranch = this.removeSourceBranch.bind(this);
this.removeBranchSuccess = this.removeBranchSuccess.bind(this);
this.removeBranchError = this.removeBranchError.bind(this);
this.$removeBranchWidget = $('.remove_source_branch_widget');
this.$removeBranchProgress = $('.remove_source_branch_in_progress');
this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
this.cleanEventListeners();
this.initEventListeners();
}
MergedButtons.prototype.cleanEventListeners = function() {
$(document).off('click', '.remove_source_branch');
$(document).off('ajax:success', '.remove_source_branch');
return $(document).off('ajax:error', '.remove_source_branch');
};
MergedButtons.prototype.initEventListeners = function() {
$(document).on('click', '.remove_source_branch', this.removeSourceBranch);
$(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess);
$(document).on('ajax:error', '.remove_source_branch', this.removeBranchError);
};
MergedButtons.prototype.removeSourceBranch = function() {
this.$removeBranchWidget.hide();
return this.$removeBranchProgress.show();
};
MergedButtons.prototype.removeBranchSuccess = function() {
gl.utils.refreshCurrentPage();
};
MergedButtons.prototype.removeBranchError = function() {
this.$removeBranchWidget.hide();
this.$removeBranchProgress.hide();
return this.$removeBranchFailed.show();
};
return MergedButtons;
})();
}).call(window);
...@@ -276,7 +276,7 @@ const normalizeNewlines = function(str) { ...@@ -276,7 +276,7 @@ const normalizeNewlines = function(str) {
var votesBlock; var votesBlock;
if (noteEntity.commands_changes) { if (noteEntity.commands_changes) {
if ('merge' in noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) {
$.get(mrRefreshWidgetUrl); Notes.checkMergeRequestStatus();
} }
if ('emoji_award' in noteEntity.commands_changes) { if ('emoji_award' in noteEntity.commands_changes) {
...@@ -424,6 +424,7 @@ const normalizeNewlines = function(str) { ...@@ -424,6 +424,7 @@ const normalizeNewlines = function(str) {
} }
gl.utils.localTimeAgo($('.js-timeago'), false); gl.utils.localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1); return this.updateNotesCount(1);
}; };
...@@ -769,7 +770,8 @@ const normalizeNewlines = function(str) { ...@@ -769,7 +770,8 @@ const normalizeNewlines = function(str) {
} }
}; };
})(this)); })(this));
// Decrement the "Discussions" counter only once
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1); return this.updateNotesCount(-1);
}; };
...@@ -1115,8 +1117,14 @@ const normalizeNewlines = function(str) { ...@@ -1115,8 +1117,14 @@ const normalizeNewlines = function(str) {
return $form; return $form;
}; };
Notes.animateAppendNote = function(noteHtml, $notesList) { Notes.checkMergeRequestStatus = function() {
const $note = $(noteHtml); if (gl.utils.getPagePath(1) === 'merge_requests') {
gl.mrWidget.checkStatus();
}
};
Notes.animateAppendNote = function(noteHTML, $notesList) {
const $note = window.$(noteHTML);
$note.addClass('fade-in-full').renderGFM(); $note.addClass('fade-in-full').renderGFM();
$notesList.append($note); $notesList.append($note);
......
/* global Flash */
import StatusIconEntityMap from '../../ci_status_icons';
export default {
props: {
stage: {
type: Object,
required: true,
},
},
data() {
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
};
},
updated() {
if (this.builds) {
this.stopDropdownClickPropagation();
}
},
methods: {
fetchBuilds(e) {
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.builds = JSON.parse(response.body).html;
})
.catch(() => {
// If dropdown is opened we'll close it.
if (this.$el.classList.contains('open')) {
$(this.$refs.dropdown).dropdown('toggle');
}
const flash = new Flash('Something went wrong on our end.');
return flash;
});
},
/**
* When the user right clicks or cmd/ctrl + click in the job name
* the dropdown should not be closed and the link should open in another tab,
* so we stop propagation of the click event inside the dropdown.
*
* Since this component is rendered multiple times per page we need to guarantee we only
* target the click event of this component.
*/
stopDropdownClickPropagation() {
$(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
.on('click', (e) => {
e.stopPropagation();
});
},
},
computed: {
buildsOrSpinner() {
return this.builds ? this.builds : this.spinner;
},
dropdownClass() {
if (this.builds) return 'js-builds-dropdown-container';
return 'js-builds-dropdown-loading builds-dropdown-loading';
},
buildStatus() {
return `Build: ${this.stage.status.label}`;
},
tooltip() {
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
},
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
},
svgHTML() {
return StatusIconEntityMap[this.stage.status.icon];
},
},
watch: {
'stage.title': function stageTitle() {
$(this.$refs.button).tooltip('destroy').tooltip();
},
},
template: `
<div>
<button
@click="fetchBuilds($event)"
:class="triggerButtonClass"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
:aria-label="stage.title"
ref="dropdown">
<span
v-html="svgHTML"
aria-hidden="true">
</span>
<i
class="fa fa-caret-down"
aria-hidden="true" />
</button>
<ul
ref="dropdown-content"
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div
class="arrow-up"
aria-hidden="true"></div>
<div
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner">
</div>
</ul>
</div>
`,
};
export default {
name: 'MRWidgetAuthor',
props: {
author: { type: Object, required: true },
showAuthorName: { type: Boolean, required: false, default: true },
showAuthorTooltip: { type: Boolean, required: false, default: false },
},
template: `
<a
:href="author.webUrl || author.web_url"
class="author-link"
:class="{ 'has-tooltip': showAuthorTooltip }"
:title="author.name">
<img
:src="author.avatarUrl || author.avatar_url"
class="avatar avatar-inline s16" />
<span
v-if="showAuthorName"
class="author">{{author.name}}
</span>
</a>
`,
};
import MRWidgetAuthor from './mr_widget_author';
export default {
name: 'MRWidgetAuthorTime',
props: {
actionText: { type: String, required: true },
author: { type: Object, required: true },
dateTitle: { type: String, required: true },
dateReadable: { type: String, required: true },
},
components: {
'mr-widget-author': MRWidgetAuthor,
},
template: `
<h4 class="js-mr-widget-author">
{{actionText}}
<mr-widget-author :author="author" />
<time
:title="dateTitle"
data-toggle="tooltip"
data-placement="top"
data-container="body">
{{dateReadable}}
</time>
</h4>
`,
};
/* global Flash */
import '~/lib/utils/datetime_utility';
import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons';
import MemoryUsage from './mr_widget_memory_usage';
import MRWidgetService from '../services/mr_widget_service';
export default {
name: 'MRWidgetDeployment',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
components: {
'mr-widget-memory-usage': MemoryUsage,
},
computed: {
svg() {
return statusClassToSvgMap.icon_status_success;
},
},
methods: {
formatDate(date) {
return gl.utils.getTimeago().format(date);
},
hasExternalUrls(deployment = {}) {
return deployment.external_url && deployment.external_url_formatted;
},
hasDeploymentTime(deployment = {}) {
return deployment.deployed_at && deployment.deployed_at_formatted;
},
hasDeploymentMeta(deployment = {}) {
return deployment.url && deployment.name;
},
stopEnvironment(deployment) {
const msg = 'Are you sure you want to stop this environment?';
const isConfirmed = confirm(msg); // eslint-disable-line
if (isConfirmed) {
MRWidgetService.stopEnvironment(deployment.stop_url)
.then(res => res.json())
.then((res) => {
if (res.redirect_url) {
gl.utils.visitUrl(res.redirect_url);
}
})
.catch(() => {
new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line
});
}
},
},
template: `
<div class="mr-widget-heading">
<div v-for="deployment in mr.deployments">
<div class="ci-widget">
<div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link">
<span
v-html="svg"
aria-hidden="true"></span>
</span>
</div>
<span>
<span
v-if="hasDeploymentMeta(deployment)">
Deployed to
</span>
<a
v-if="hasDeploymentMeta(deployment)"
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-meta">
{{deployment.name}}
</a>
<span
v-if="hasExternalUrls(deployment)">
on
</span>
<a
v-if="hasExternalUrls(deployment)"
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url">
<i
class="fa fa-external-link"
aria-hidden="true" />
{{deployment.external_url_formatted}}
</a>
<span
v-if="hasDeploymentTime(deployment)"
:data-title="deployment.deployed_at_formatted"
class="js-deploy-time"
data-toggle="tooltip"
data-placement="top">
{{formatDate(deployment.deployed_at)}}
</span>
<button
type="button"
v-if="deployment.stop_url"
@click="stopEnvironment(deployment)"
class="btn btn-default btn-xs">
Stop environment
</button>
</span>
</div>
<mr-widget-memory-usage
v-if="deployment.metrics_url"
:mr="mr"
:service="service"
:metricsUrl="deployment.metrics_url"
/>
</div>
</div>
`,
};
require('../../lib/utils/text_utility');
export default {
name: 'MRWidgetHeader',
props: {
mr: { type: Object, required: true },
},
computed: {
shouldShowCommitsBehindText() {
return this.mr.divergedCommitsCount > 0;
},
commitsText() {
return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
},
},
methods: {
isBranchTitleLong(branchTitle) {
return branchTitle.length > 32;
},
},
template: `
<div class="mr-source-target">
<div
v-if="mr.isOpen"
class="pull-right">
<a
href="#modal_merge_info"
data-toggle="modal"
class="btn inline btn-grouped btn-sm">
Check out branch
</a>
<span class="dropdown inline prepend-left-5">
<a
class="btn btn-sm dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
role="button">
<i
class="fa fa-download"
aria-hidden="true" />
<i
class="fa fa-caret-down"
aria-hidden="true" />
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li>
<a
:href="mr.emailPatchesPath"
download>
Email patches
</a>
</li>
<li>
<a
:href="mr.plainDiffPath"
download>
Plain diff
</a>
</li>
</ul>
</span>
</div>
<div class="normal">
<b>Request to merge</b>
<span
class="label-branch"
:class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}"
:title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
data-placement="bottom"
v-html="mr.sourceBranchLink"></span>
<button
class="btn btn-transparent btn-clipboard has-tooltip"
data-title="Copy branch name to clipboard"
:data-clipboard-text="mr.sourceBranch">
<i
aria-hidden="true"
class="fa fa-clipboard"></i>
</button>
<b>into</b>
<span
class="label-branch"
:class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}"
:title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
data-placement="bottom">
<a
:href="mr.targetBranchCommitsPath">
{{mr.targetBranch}}
</a>
</span>
<span
v-if="shouldShowCommitsBehindText"
class="diverged-commits-count">
({{mr.divergedCommitsCount}} {{commitsText}} behind)
</span>
</div>
</div>
`,
};
import statusCodes from '~/lib/utils/http_status';
import MemoryGraph from '../../vue_shared/components/memory_graph';
import MRWidgetService from '../services/mr_widget_service';
export default {
name: 'MemoryUsage',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
metricsUrl: { type: String, required: true },
},
data() {
return {
// memoryFrom: 0,
// memoryTo: 0,
memoryMetrics: [],
hasMetrics: false,
loadFailed: false,
loadingMetrics: true,
backOffRequestCounter: 0,
};
},
components: {
'mr-memory-graph': MemoryGraph,
},
methods: {
computeGraphData(metrics) {
this.loadingMetrics = false;
const { memory_values } = metrics;
// if (memory_previous.length > 0) {
// this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2);
// }
//
// if (memory_current.length > 0) {
// this.memoryTo = Number(memory_current[0].value[1]).toFixed(2);
// }
if (memory_values.length > 0) {
this.hasMetrics = true;
this.memoryMetrics = memory_values[0].values;
}
},
},
mounted() {
this.$props.loadingMetrics = true;
gl.utils.backOff((next, stop) => {
MRWidgetService.fetchMetrics(this.$props.metricsUrl)
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
} else {
stop(res);
}
} else {
stop(res);
}
})
.catch(stop);
})
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
return res;
}
return res.json();
})
.then((res) => {
this.computeGraphData(res.metrics);
return res;
})
.catch(() => {
this.$props.loadFailed = true;
});
},
template: `
<div class="mr-info-list mr-memory-usage">
<div class="legend"></div>
<p
v-if="loadingMetrics"
class="usage-info usage-info-loading">
<i
class="fa fa-spinner fa-spin usage-info-load-spinner"
aria-hidden="true" />Loading deployment statistics.
</p>
<p
v-if="!hasMetrics && !loadingMetrics"
class="usage-info usage-info-loading">
Deployment statistics are not available currently.
</p>
<p
v-if="hasMetrics"
class="usage-info">
Deployment memory usage:
</p>
<p
v-if="loadFailed"
class="usage-info">
Failed to load deployment statistics.
</p>
<mr-memory-graph
v-if="hasMetrics"
:metrics="memoryMetrics"
height="25"
width="100" />
</div>
`,
};
export default {
name: 'MRWidgetMergeHelp',
props: {
missingBranch: { type: String, required: false, default: '' },
},
template: `
<section class="mr-widget-help">
<template
v-if="missingBranch">
If the {{missingBranch}} branch exists in your local repository, you
</template>
<template v-else>
You
</template>
can merge this merge request manually using the
<a
data-toggle="modal"
href="#modal_merge_info">
command line.
</a>
</section>
`,
};
import PipelineStage from '../../pipelines/components/stage';
import pipelineStatusIcon from '../../vue_shared/components/pipeline_status_icon';
import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons';
export default {
name: 'MRWidgetPipeline',
props: {
mr: { type: Object, required: true },
},
components: {
'pipeline-stage': PipelineStage,
'pipeline-status-icon': pipelineStatusIcon,
},
computed: {
hasCIError() {
const { hasCI, ciStatus } = this.mr;
return hasCI && !ciStatus;
},
svg() {
return statusClassToSvgMap.icon_status_failed;
},
stageText() {
return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage';
},
},
template: `
<div class="mr-widget-heading">
<div class="ci-widget">
<template v-if="hasCIError">
<div class="ci-status-icon ci-status-icon-failed js-ci-error">
<span class="js-icon-link icon-link">
<span
v-html="svg"
aria-hidden="true"></span>
</span>
</div>
<span>Could not connect to the CI server. Please check your settings and try again.</span>
</template>
<template v-else>
<pipeline-status-icon :pipelineStatus="mr.pipelineDetailedStatus" />
<span>
Pipeline
<a
:href="mr.pipeline.path"
class="pipeline-id">#{{mr.pipeline.id}}</a>
{{mr.pipeline.details.status.label}}
with {{stageText}}
</span>
<div class="mr-widget-pipeline-graph">
<div class="stage-cell">
<div
v-if="mr.pipeline.details.stages.length > 0"
v-for="stage in mr.pipeline.details.stages"
class="stage-container dropdown js-mini-pipeline-graph">
<pipeline-stage :stage="stage" />
</div>
</div>
</div>
<span>
for
<a
:href="mr.pipeline.commit.commit_path"
class="monospace js-commit-link">
{{mr.pipeline.commit.short_id}}</a>.
</span>
<span
v-if="mr.pipeline.coverage"
class="js-mr-coverage">
Coverage {{mr.pipeline.coverage}}%.
</span>
</template>
</div>
</div>
`,
};
export default {
name: 'MRWidgetRelatedLinks',
props: {
relatedLinks: { type: Object, required: true },
},
computed: {
hasLinks() {
const { closing, mentioned, assignToMe } = this.relatedLinks;
return closing || mentioned || assignToMe;
},
},
methods: {
hasMultipleIssues(text) {
return !text ? false : text.match(/<\/a> and <a/);
},
issueLabel(field) {
return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue';
},
verbLabel(field) {
return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is';
},
},
template: `
<section
v-if="hasLinks"
class="mr-info-list mr-links">
<div class="legend"></div>
<p v-if="relatedLinks.closing">
Closes {{issueLabel('closing')}}
<span v-html="relatedLinks.closing"></span>.
</p>
<p v-if="relatedLinks.mentioned">
<span class="capitalize">{{issueLabel('mentioned')}}</span>
<span v-html="relatedLinks.mentioned"></span>
{{verbLabel('mentioned')}} mentioned but will not be closed.
</p>
<p v-if="relatedLinks.assignToMe">
<span v-html="relatedLinks.assignToMe"></span>
</p>
</section>
`,
};
export default {
name: 'MRWidgetArchived',
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
This project is archived, write access has been disabled.
</span>
</div>
`,
};
export default {
name: 'MRWidgetAutoMergeFailed',
props: {
mr: { type: Object, required: true },
},
template: `
<div class="mr-widget-body">
<button
class="btn btn-success btn-small"
disabled="true"
type="button">
Merge
</button>
<span class="bold danger">
This merge request failed to be merged automatically.
</span>
<div class="merge-error-text">
{{mr.mergeError}}
</div>
</div>
`,
};
export default {
name: 'MRWidgetChecking',
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
Checking ability to merge automatically.
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
</span>
</div>
`,
};
import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
export default {
name: 'MRWidgetClosed',
props: {
mr: { type: Object, required: true },
},
components: {
'mr-widget-author-and-time': mrWidgetAuthorTime,
},
template: `
<div class="mr-widget-body">
<mr-widget-author-and-time
actionText="Closed by"
:author="mr.closedBy"
:dateTitle="mr.updatedAt"
:dateReadable="mr.closedAt"
/>
<section>
<p>
The changes were not merged into
<a
:href="mr.targetBranchCommitsPath"
class="label-branch">
{{mr.targetBranch}}</a>.
</p>
</section>
</div>
`,
};
export default {
name: 'MRWidgetConflicts',
props: {
mr: { type: Object, required: true },
},
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
There are merge conflicts.
<span v-if="!mr.canMerge">
Resolve these conflicts or ask someone with write access to this repository to merge it locally.
</span>
</span>
<div
v-if="mr.canMerge"
class="btn-group">
<a
v-if="mr.conflictResolutionPath"
:href="mr.conflictResolutionPath"
class="btn btn-default btn-xs js-resolve-conflicts-button">
Resolve conflicts
</a>
<a
v-if="mr.canMerge"
class="btn btn-default btn-xs js-merge-locally-button"
data-toggle="modal"
href="#modal_merge_info">
Merge locally
</a>
</div>
</div>
`,
};
import eventHub from '../../event_hub';
export default {
name: 'MRWidgetFailedToMerge',
props: {
mr: { type: Object, required: true },
},
data() {
return {
timer: 10,
isRefreshing: false,
};
},
mounted() {
setInterval(() => {
this.updateTimer();
}, 1000);
},
created() {
eventHub.$emit('DisablePolling');
},
computed: {
timerText() {
return this.timer > 1 ? `${this.timer} seconds` : 'a second';
},
},
methods: {
refresh() {
this.isRefreshing = true;
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('EnablePolling');
},
updateTimer() {
this.timer = this.timer - 1;
if (this.timer === 0) {
this.refresh();
}
},
},
template: `
<div class="mr-widget-body">
<button
class="btn btn-success btn-small"
disabled="true"
type="button">
Merge
</button>
<span
v-if="!isRefreshing"
class="bold danger">
<span
class="has-error-message"
v-if="mr.mergeError">
{{mr.mergeError}}
</span>
<span v-else>Merge failed.</span>
<span
:class="{ 'has-custom-error': mr.mergeError }">
Refreshing in {{timerText}} to show the updated status...
</span>
<button
@click="refresh"
class="btn btn-default btn-xs js-refresh-button"
type="button">
Refresh now
</button>
</span>
<span
v-if="isRefreshing"
class="bold js-refresh-label">
Refreshing now...
</span>
</div>
`,
};
export default {
name: 'MRWidgetLocked',
props: {
mr: { type: Object, required: true },
},
template: `
<div class="mr-widget-body mr-state-locked">
<span class="state-label">Locked</span>
This merge request is in the process of being merged, during which time it is locked and cannot be closed.
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
<section class="mr-info-list mr-links">
<div class="legend"></div>
<p>
The changes will be merged into
<span class="label-branch">
<a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
</span>
</p>
</section>
</div>
`,
};
/* global Flash */
import MRWidgetAuthor from '../../components/mr_widget_author';
import eventHub from '../../event_hub';
export default {
name: 'MRWidgetMergeWhenPipelineSucceeds',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
components: {
'mr-widget-author': MRWidgetAuthor,
},
data() {
return {
isCancellingAutoMerge: false,
isRemovingSourceBranch: false,
};
},
computed: {
canRemoveSourceBranch() {
const { shouldRemoveSourceBranch, canRemoveSourceBranch,
mergeUserId, currentUserId } = this.mr;
return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId;
},
},
methods: {
cancelAutomaticMerge() {
this.isCancellingAutoMerge = true;
this.service.cancelAutomaticMerge()
.then(res => res.json())
.then((res) => {
eventHub.$emit('UpdateWidgetData', res);
})
.catch(() => {
this.isCancellingAutoMerge = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
removeSourceBranch() {
const options = {
sha: this.mr.sha,
merge_when_pipeline_succeeds: true,
should_remove_source_branch: true,
};
this.isRemovingSourceBranch = true;
this.service.mergeResource.save(options)
.then(res => res.json())
.then((res) => {
if (res.status === 'merge_when_pipeline_succeeds') {
eventHub.$emit('MRWidgetUpdateRequested');
}
})
.catch(() => {
this.isRemovingSourceBranch = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
},
template: `
<div class="mr-widget-body">
<h4>
Set by
<mr-widget-author :author="mr.setToMWPSBy" />
to be merged automatically when the pipeline succeeds.
<a
v-if="mr.canCancelAutomaticMerge"
@click.prevent="cancelAutomaticMerge"
:disabled="isCancellingAutoMerge"
role="button"
href="#"
class="btn btn-xs btn-default js-cancel-auto-merge">
<i
v-if="isCancellingAutoMerge"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Cancel automatic merge
</a>
</h4>
<section class="mr-info-list">
<div class="legend"></div>
<p>The changes will be merged into
<a
:href="mr.targetBranchPath"
class="label-branch">
{{mr.targetBranch}}
</a>
</p>
<p v-if="mr.shouldRemoveSourceBranch">
The source branch will be removed.
</p>
<p
v-else
class="with-button">
The source branch will not be removed.
<a
v-if="canRemoveSourceBranch"
:disabled="isRemovingSourceBranch"
@click.prevent="removeSourceBranch"
role="button"
class="btn btn-xs btn-default js-remove-source-branch"
href="#">
<i
v-if="isRemovingSourceBranch"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Remove source branch
</a>
</p>
</section>
</div>
`,
};
/* global Flash */
import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
import eventHub from '../../event_hub';
export default {
name: 'MRWidgetMerged',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
components: {
'mr-widget-author-and-time': mrWidgetAuthorTime,
},
data() {
return {
isMakingRequest: false,
};
},
computed: {
shouldShowRemoveSourceBranch() {
const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr;
return !sourceBranchRemoved && canRemoveSourceBranch &&
!this.isMakingRequest && !isRemovingSourceBranch;
},
shouldShowSourceBranchRemoving() {
const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
},
shouldShowMergedButtons() {
const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath,
cherryPickInForkPath } = this.mr;
return canRevertInCurrentMR || canCherryPickInCurrentMR ||
revertInForkPath || cherryPickInForkPath;
},
},
methods: {
removeSourceBranch() {
this.isMakingRequest = true;
this.service.removeSourceBranch()
.then(res => res.json())
.then((res) => {
if (res.message === 'Branch was removed') {
eventHub.$emit('MRWidgetUpdateRequested', () => {
this.isMakingRequest = false;
});
}
})
.catch(() => {
this.isMakingRequest = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
},
template: `
<div class="mr-widget-body">
<mr-widget-author-and-time
actionText="Merged by"
:author="mr.mergedBy"
:dateTitle="mr.updatedAt"
:dateReadable="mr.mergedAt" />
<section class="mr-info-list">
<div class="legend"></div>
<p>
The changes were merged into
<span class="label-branch">
<a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
</span>
</p>
<p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p>
<p v-if="shouldShowRemoveSourceBranch">
You can remove source branch now.
<button
@click="removeSourceBranch"
:class="{ disabled: isMakingRequest }"
type="button"
class="btn btn-xs btn-default js-remove-branch-button">
Remove Source Branch
</button>
</p>
<p v-if="shouldShowSourceBranchRemoving">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
The source branch is being removed.
</p>
</section>
<div
v-if="shouldShowMergedButtons"
class="merged-buttons clearfix">
<a
v-if="mr.canRevertInCurrentMR"
class="btn btn-close btn-sm has-tooltip"
href="#modal-revert-commit"
data-toggle="modal"
data-container="body"
title="Revert this merge request in a new merge request">
Revert
</a>
<a
v-else-if="mr.revertInForkPath"
class="btn btn-close btn-sm has-tooltip"
data-method="post"
:href="mr.revertInForkPath"
title="Revert this merge request in a new merge request">
Revert
</a>
<a
v-if="mr.canCherryPickInCurrentMR"
class="btn btn-default btn-sm has-tooltip"
href="#modal-cherry-pick-commit"
data-toggle="modal"
data-container="body"
title="Cherry-pick this merge request in a new merge request">
Cherry-pick
</a>
<a
v-else-if="mr.cherryPickInForkPath"
class="btn btn-default btn-sm has-tooltip"
data-method="post"
:href="mr.cherryPickInForkPath"
title="Cherry-pick this merge request in a new merge request">
Cherry-pick
</a>
</div>
</div>
`,
};
import mrWidgetMergeHelp from '../../components/mr_widget_merge_help';
export default {
name: 'MRWidgetMissingBranch',
props: {
mr: { type: Object, required: true },
},
components: {
'mr-widget-merge-help': mrWidgetMergeHelp,
},
computed: {
missingBranchName() {
return this.mr.sourceBranchRemoved ? 'source' : 'target';
},
},
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold js-branch-text">
<span class="capitalize">
{{missingBranchName}}
</span> branch does not exist.
Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch.
</span>
<mr-widget-merge-help
:missing-branch="missingBranchName" />
</div>
`,
};
export default {
name: 'MRWidgetNotAllowed',
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
Ready to be merged automatically.
Ask someone with write access to this repository to merge this request.
</span>
</div>
`,
};
export default {
name: 'MRWidgetNothingToMerge',
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
There is nothing to merge from source branch into target branch.
Please push new commits or use a different branch.
</span>
</div>
`,
};
export default {
name: 'MRWidgetPipelineBlocked',
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.
</span>
</div>
`,
};
export default {
name: 'MRWidgetPipelineBlocked',
template: `
<div class="mr-widget-body">
<button
class="btn btn-success btn-small"
disabled="true"
type="button">
Merge
</button>
<span class="bold">
The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.
</span>
</div>
`,
};
/*
The squash-before-merge button is EE only, but it's located right in the middle
of the readyToMerge state component template.
If we didn't declare this component in CE, we'd need to maintain a separate copy
of the readyToMergeState template in EE, which is pretty big and likely to change.
Instead, in CE, we declare the component, but it's hidden and is configured to do nothing.
In EE, the configuration extends this object to add a functioning squash-before-merge
button.
*/
export default {
template: '',
};
export default {
name: 'MRWidgetUnresolvedDiscussions',
props: {
mr: { type: Object, required: true },
},
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
There are unresolved discussions. Please resolve these discussions
<span v-if="mr.canCreateIssue">or</span>
<span v-else>.</span>
</span>
<a
v-if="mr.createIssueToResolveDiscussionsPath"
:href="mr.createIssueToResolveDiscussionsPath"
class="btn btn-default btn-xs js-create-issue">
Create an issue to resolve them later
</a>
</div>
`,
};
/* global Flash */
import eventHub from '../../event_hub';
export default {
name: 'MRWidgetWIP',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
data() {
return {
isMakingRequest: false,
};
},
methods: {
removeWIP() {
this.isMakingRequest = true;
this.service.removeWIP()
.then(res => res.json())
.then((res) => {
eventHub.$emit('UpdateWidgetData', res);
new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
$('.merge-request .detail-page-description .title').text(this.mr.title);
})
.catch(() => {
this.isMakingRequest = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
},
template: `
<div class="mr-widget-body">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge</button>
<span class="bold">
This merge request is currently Work In Progress and therefore unable to merge
</span>
<template v-if="mr.removeWIPPath">
<i
class="fa fa-question-circle has-tooltip"
title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." />
<button
@click="removeWIP"
:disabled="isMakingRequest"
type="button"
class="btn btn-default btn-xs js-remove-wip">
<i
v-if="isMakingRequest"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Resolve WIP status
</button>
</template>
</div>
`,
};
export { default as Vue } from 'vue';
export { default as SmartInterval } from '~/smart_interval';
export { default as WidgetHeader } from './components/mr_widget_header';
export { default as WidgetMergeHelp } from './components/mr_widget_merge_help';
export { default as WidgetPipeline } from './components/mr_widget_pipeline';
export { default as WidgetDeployment } from './components/mr_widget_deployment';
export { default as WidgetRelatedLinks } from './components/mr_widget_related_links';
export { default as MergedState } from './components/states/mr_widget_merged';
export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge';
export { default as ClosedState } from './components/states/mr_widget_closed';
export { default as LockedState } from './components/states/mr_widget_locked';
export { default as WipState } from './components/states/mr_widget_wip';
export { default as ArchivedState } from './components/states/mr_widget_archived';
export { default as ConflictsState } from './components/states/mr_widget_conflicts';
export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge';
export { default as MissingBranchState } from './components/states/mr_widget_missing_branch';
export { default as NotAllowedState } from './components/states/mr_widget_not_allowed';
export { default as ReadyToMergeState } from './ee/components/states/mr_widget_ready_to_merge';
export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions';
export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked';
export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds';
export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed';
export { default as CheckingState } from './components/states/mr_widget_checking';
export { default as MRWidgetStore } from './ee/stores/mr_widget_store';
export { default as MRWidgetService } from './ee/services/mr_widget_service';
export { default as eventHub } from './event_hub';
export { default as getStateKey } from './ee/stores/get_state_key';
export { default as mrWidgetOptions } from './ee/mr_widget_options';
export { default as stateMaps } from './ee/stores/state_maps';
export { default as SquashBeforeMerge } from './ee/components/states/mr_widget_squash_before_merge';
import Vue from 'vue'; /* global Flash */
import MRWidgetAuthor from '../../../components/mr_widget_author';
import eventHub from '../../../event_hub';
require('../approvals_store'); export default {
require('../approvals_api');
Vue.component('approvals-body', {
name: 'approvals-body', name: 'approvals-body',
props: { props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
approvedBy: { approvedBy: {
type: Array, type: Array,
required: false, required: false,
...@@ -27,6 +34,9 @@ Vue.component('approvals-body', { ...@@ -27,6 +34,9 @@ Vue.component('approvals-body', {
required: false, required: false,
}, },
}, },
components: {
'mr-widget-author': MRWidgetAuthor,
},
data() { data() {
return { return {
approving: false, approving: false,
...@@ -37,29 +47,6 @@ Vue.component('approvals-body', { ...@@ -37,29 +47,6 @@ Vue.component('approvals-body', {
const baseString = `${this.approvalsLeft} more approval`; const baseString = `${this.approvalsLeft} more approval`;
return this.approvalsLeft === 1 ? baseString : `${baseString}s`; return this.approvalsLeft === 1 ? baseString : `${baseString}s`;
}, },
approverNamesStringified() {
const approvers = this.suggestedApprovers;
if (!approvers) {
return '';
}
const separator = this.approvalsLeft === approvers.length ? 'and' : 'or';
const serialComma = approvers.length > 2 ? ',' : '';
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}${serialComma} ${separator} `;
} else if (index === approvers.length - 1) { // last index
return nextMemo;
}
return `${nextMemo}, `;
}, '');
},
showApproveButton() { showApproveButton() {
return this.userCanApprove && !this.userHasApproved; return this.userCanApprove && !this.userHasApproved;
}, },
...@@ -70,27 +57,44 @@ Vue.component('approvals-body', { ...@@ -70,27 +57,44 @@ Vue.component('approvals-body', {
methods: { methods: {
approveMergeRequest() { approveMergeRequest() {
this.approving = true; this.approving = true;
return gl.ApprovalsStore.approve().then(() => { this.service.approveMergeRequest()
this.approving = false; .then((data) => {
}); this.mr.setApprovals(data);
eventHub.$emit('MRWidgetUpdateRequested');
this.approving = false;
})
.catch(() => {
this.approving = false;
new Flash('An error occured while submitting your approval.'); // eslint-disable-line
});
}, },
}, },
beforeCreate() {
gl.ApprovalsStore.initStoreOnce();
},
template: ` template: `
<div class='approvals-body mr-widget-footer mr-approvals-footer'> <div class="approvals-body">
<h4> Requires {{ approvalsRequiredStringified }} <span v-if="showApproveButton" class="approvals-approve-button-wrap">
<span v-if='showSuggestedApprovers'> (from {{ approverNamesStringified }}) </span>
</h4>
<div v-if='showApproveButton' class='append-bottom-10'>
<button <button
:disabled='approving' :disabled="approving"
@click='approveMergeRequest' @click="approveMergeRequest"
class='btn btn-primary approve-btn'> class="btn btn-primary btn-small approve-btn">
Approve merge request <i
v-if="approving"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Approve
</button> </button>
</div> </span>
<span class="approvals-required-text bold">
Requires {{approvalsRequiredStringified}}
<span v-if="showSuggestedApprovers">
<span class="dash">&mdash;</span>
<mr-widget-author
v-for="approver in suggestedApprovers"
:key="approver.username"
:author="approver"
:show-author-name="false"
:show-author-tooltip="true" />
</span>
</span>
</div> </div>
`, `,
}); };
/* global Flash */
import pendingAvatarSvg from 'icons/_icon_dotted_circle.svg';
import LinkToMemberAvatar from '~/vue_shared/components/link_to_member_avatar';
import eventHub from '../../../event_hub';
export default {
name: 'approvals-footer',
props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
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 {
unapproving: false,
pendingAvatarSvg,
};
},
components: {
'link-to-member-avatar': LinkToMemberAvatar,
},
computed: {
showUnapproveButton() {
return this.userHasApproved && !this.userCanApprove;
},
},
methods: {
unapproveMergeRequest() {
this.unapproving = true;
this.service.unapproveMergeRequest()
.then((data) => {
this.mr.setApprovals(data);
eventHub.$emit('MRWidgetUpdateRequested');
this.unapproving = false;
})
.catch(() => {
this.unapproving = false;
new Flash('An error occured while removing your approval.'); // eslint-disable-line
});
},
},
template: `
<div v-if="approvedBy.length" class="approved-by-users approvals-footer clearfix mr-info-list">
<div class="legend"></div>
<div>
<p class="approvers-prefix">Approved by</p>
<div class="approvers-list">
<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"
:show-tooltip="true" />
</span>
<span class="potential-approvers-list" v-for="n in approvalsLeft">
<link-to-member-avatar
:clickable="false"
:avatar-html="pendingAvatarSvg"
:show-tooltip="false"
extra-link-class="hide-asset" />
</span>
</div>
<span class="unapprove-btn-wrap" v-if="showUnapproveButton">
<button
:disabled="unapproving"
@click="unapproveMergeRequest"
class="btn btn-sm">
<i
v-if="unapproving"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Remove your approval
</button>
</span>
</div>
</div>
`,
};
/* global Flash */
import ApprovalsBody from './approvals_body';
import ApprovalsFooter from './approvals_footer';
export default {
name: 'MRWidgetApprovals',
props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
fetchingApprovals: true,
};
},
components: {
'approvals-body': ApprovalsBody,
'approvals-footer': ApprovalsFooter,
},
created() {
const flashErrorMessage = 'An error occured while retrieving approval data for this merge request.';
this.service.fetchApprovals()
.then((data) => {
this.mr.setApprovals(data);
this.fetchingApprovals = false;
})
.catch(() => new Flash(flashErrorMessage));
},
template: `
<div
v-if="mr.approvalsRequired"
class="mr-widget-approvals-container mr-widget-body">
<div
v-show="fetchingApprovals"
class="mr-approvals-loading-state">
<span class="approvals-loading-text bold">
Checking approval status for this merge request.
</span>
<i class="fa fa-spinner fa-spin" />
</div>
<div
v-if="!fetchingApprovals"
class="approvals-components">
<approvals-body
:mr="mr"
:service="service"
:user-can-approve="mr.approvals.user_can_approve"
:user-has-approved="mr.approvals.user_has_approved"
:approved-by="mr.approvals.approved_by"
:approvals-left="mr.approvals.approvals_left"
:suggested-approvers="mr.approvals.suggested_approvers" />
<approvals-footer
:mr="mr"
:service="service"
:user-can-approve="mr.approvals.user_can_approve"
:user-has-approved="mr.approvals.user_has_approved"
:approved-by="mr.approvals.approved_by"
:approvals-left="mr.approvals.approvals_left" />
</div>
</div>
`,
};
import eventHub from '../../../event_hub';
import ReadyToMergeState from '../../../components/states/mr_widget_ready_to_merge';
import SquashBeforeMerge from './mr_widget_squash_before_merge';
export default {
extends: ReadyToMergeState,
name: 'MRWidgetReadyToMerge',
components: {
'squash-before-merge': SquashBeforeMerge,
},
data() {
return {
additionalParams: {
squash: this.mr.squash,
},
};
},
methods: {
// called in CE super component before form submission
setAdditionalParams(options) {
if (this.additionalParams) {
Object.assign(options, this.additionalParams);
}
},
},
created() {
eventHub.$on('MRWidgetUpdateSquash', (val) => {
this.additionalParams.squash = val;
});
},
};
/* global Flash */
import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../../event_hub';
export default {
props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isMakingRequest: false,
};
},
methods: {
rebase() {
this.isMakingRequest = true;
this.service.rebase().then(() => {
simplePoll((continuePolling, stopPolling) => {
this.service.poll()
.then(res => res.json())
.then((res) => {
if (res.rebase_in_progress) {
continuePolling();
} else {
this.isMakingRequest = false;
eventHub.$emit('MRWidgetUpdateRequested');
stopPolling();
}
})
.catch(() => {
this.isMakingRequest = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
stopPolling();
});
});
}).catch(() => {
this.isMakingRequest = false;
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
},
template: `
<div class="mr-widget-body">
<div class="rebase-state-find-class-convention">
<template v-if="mr.rebaseInProgress || isMakingRequest">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Rebase in progress. This merge request is in the process of being rebased.
</span>
</template>
<template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
<button
type="button"
class="btn btn-success btn-small"
disabled="true">
Merge
</button>
<span class="bold">
Fast-forward merge is not possible.
Rebase the source branch onto
<span class="label-branch">{{mr.targetBranch}}</span>
to allow this merge request to be merged.
</span>
</template>
<template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest">
<div class="accept-merge-holder clearfix js-toggle-container accept-action">
<button
class="btn btn-small btn-reopen btn-success"
:disabled="mr.approvalsLeft || isMakingRequest"
@click="rebase">
<i
v-if="isMakingRequest"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Rebase
</button>
<span class="bold">
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.
</span>
</div>
<div class="mr-info-list">
<div class="legend"></div>
<p v-if="mr.approvalsLeft">
Rebasing is disabled until merge request has been approved.
</p>
</div>
</template>
</div>
</div>
`,
};
export default {
props: {
mr: {
type: Object,
required: true,
},
},
template: `
<div>
<button type="button" class="btn btn-success btn-small" disabled="true">Merge</button>
<span class="bold">
Merge requests are read-only in a secondary Geo node.
</span>
<a
:href="mr.geoSecondaryHelpPath"
data-title="About this feature"
data-toggle="tooltip"
data-placement="bottom"
target="_blank"
rel="noopener noreferrer nofollow"
data-container="body">
<i class="fa fa-question-circle"></i>
</a>
</div>
`,
};
import eventHub from '../../../event_hub';
import CESquashBeforeMerge from '../../components/states/mr_widget_squash_before_merge';
export default {
extends: CESquashBeforeMerge,
props: {
mr: {
type: Object,
required: true,
},
isMergeButtonDisabled: {
type: Boolean,
required: true,
},
},
data() {
return {
squashBeforeMerge: this.mr.squash,
};
},
methods: {
updateSquashModel() {
eventHub.$emit('MRWidgetUpdateSquash', this.squashBeforeMerge);
},
},
template: `
<div class="accept-control spacing inline">
<label class="merge-param-checkbox">
<input
type="checkbox"
name="squash"
:disabled="isMergeButtonDisabled"
v-model="squashBeforeMerge"
@change="updateSquashModel"/>
Squash commits
</label>
<a
:href="mr.squashBeforeMergeHelpPath"
data-title="About this feature"
data-toggle="tooltip"
data-placement="bottom"
target="_blank"
rel="noopener noreferrer nofollow"
data-container="body">
<i
class="fa fa-question-circle"
aria-hidden="true"></i>
</a>
</div>`,
};
import CEWidgetOptions from '../mr_widget_options';
import WidgetApprovals from './components/approvals/mr_widget_approvals';
import GeoSecondaryNode from './components/states/mr_widget_secondary_geo_node';
import RebaseState from './components/states/mr_widget_rebase';
export default {
extends: CEWidgetOptions,
components: {
'mr-widget-approvals': WidgetApprovals,
'mr-widget-geo-secondary-node': GeoSecondaryNode,
'mr-widget-rebase': RebaseState,
},
computed: {
shouldRenderApprovals() {
return this.mr.approvalsRequired;
},
},
template: `
<div class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline
v-if="shouldRenderPipelines"
:mr="mr" />
<mr-widget-deployment
v-if="shouldRenderDeployments"
:mr="mr"
:service="service" />
<mr-widget-approvals
v-if="mr.approvalsRequired"
:mr="mr"
:service="service" />
<component
:is="componentName"
:mr="mr"
:service="service" />
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:related-links="mr.relatedLinks" />
<mr-widget-merge-help v-if="shouldRenderMergeHelp" />
</div>
`,
};
import Vue from 'vue';
import CEWidgetService from '../../services/mr_widget_service';
export default class MRWidgetService extends CEWidgetService {
constructor(mr) {
super(mr);
this.approvalsResource = Vue.resource(mr.approvalsPath);
this.rebaseResource = Vue.resource(mr.rebasePath);
}
fetchApprovals() {
return this.approvalsResource.get()
.then(res => res.json());
}
approveMergeRequest() {
return this.approvalsResource.save()
.then(res => res.json());
}
unapproveMergeRequest() {
return this.approvalsResource.delete()
.then(res => res.json());
}
rebase() {
return this.rebaseResource.save();
}
}
import CEGetStateKey from '../../stores/get_state_key';
export default function (data) {
if (this.isGeoSecondaryNode) {
return 'geoSecondaryNode';
}
if (this.shouldBeRebased) {
return 'rebase';
}
return CEGetStateKey.call(this, data);
}
import CEMergeRequestStore from '../../stores/mr_widget_store';
export default class MergeRequestStore extends CEMergeRequestStore {
setData(data) {
this.initGeo(data);
this.initSquashBeforeMerge(data);
this.initRebase(data);
this.initApprovals(data);
super.setData(data);
}
initSquashBeforeMerge(data) {
this.squashBeforeMergeHelpPath = this.squashBeforeMergeHelpPath
|| data.squash_before_merge_help_path;
this.enableSquashBeforeMerge = true;
}
initRebase(data) {
this.shouldBeRebased = !!data.should_be_rebased;
this.canPushToSourceBranch = data.can_push_to_source_branch;
this.rebaseInProgress = data.rebase_in_progress;
this.approvalsLeft = !data.approved;
this.rebasePath = data.rebase_path;
this.ffOnlyEnabled = data.ff_only_enabled;
}
initGeo(data) {
this.isGeoSecondaryNode = this.isGeoSecondaryNode || data.is_geo_secondary_node;
this.geoSecondaryHelpPath = this.geoSecondaryHelpPath || data.geo_secondary_help_path;
}
initApprovals(data) {
this.isApproved = data.approved || false;
this.approvals = this.approvals || null;
this.approvalsPath = data.approvals_path || this.approvalsPath;
this.approvalsRequired = Boolean(this.approvalsPath);
}
setApprovals(data) {
this.approvals = data;
this.approvalsLeft = !!data.approvals_left;
this.isApproved = data.approved || !this.approvalsLeft || false;
this.preventMerge = this.approvalsRequired && this.approvalsLeft;
}
}
import stateMaps from '../../stores/state_maps';
stateMaps.stateToComponentMap.geoSecondaryNode = 'mr-widget-geo-secondary-node';
stateMaps.stateToComponentMap.rebase = 'mr-widget-rebase';
stateMaps.statesToShowHelpWidget.push('rebase');
export default {
stateToComponentMap: stateMaps.stateToComponentMap,
statesToShowHelpWidget: stateMaps.statesToShowHelpWidget,
};
import Vue from 'vue';
export default new Vue();
import {
Vue,
mrWidgetOptions,
} from './dependencies';
document.addEventListener('DOMContentLoaded', () => {
const vm = new Vue(mrWidgetOptions);
window.gl.mrWidget = {
checkStatus: vm.checkStatus,
};
});
/* global Flash */
import {
WidgetHeader,
WidgetMergeHelp,
WidgetPipeline,
WidgetDeployment,
WidgetRelatedLinks,
MergedState,
ClosedState,
LockedState,
WipState,
ArchivedState,
ConflictsState,
NothingToMergeState,
MissingBranchState,
NotAllowedState,
ReadyToMergeState,
UnresolvedDiscussionsState,
PipelineBlockedState,
PipelineFailedState,
FailedToMerge,
MergeWhenPipelineSucceedsState,
AutoMergeFailed,
CheckingState,
MRWidgetStore,
MRWidgetService,
eventHub,
stateMaps,
SquashBeforeMerge,
} from './dependencies';
export default {
el: '#js-vue-mr-widget',
name: 'MRWidget',
data() {
const store = new MRWidgetStore(gl.mrWidgetData);
const service = this.createService(store);
return {
mr: store,
service,
};
},
computed: {
componentName() {
return stateMaps.stateToComponentMap[this.mr.state];
},
shouldRenderMergeHelp() {
return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
},
shouldRenderPipelines() {
return Object.keys(this.mr.pipeline).length || this.mr.hasCI;
},
shouldRenderRelatedLinks() {
return this.mr.relatedLinks;
},
shouldRenderDeployments() {
return this.mr.deployments.length;
},
},
methods: {
createService(store) {
const endpoints = {
mergePath: store.mergePath,
mergeCheckPath: store.mergeCheckPath,
cancelAutoMergePath: store.cancelAutoMergePath,
removeWIPPath: store.removeWIPPath,
sourceBranchPath: store.sourceBranchPath,
ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
statusPath: store.statusPath,
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
approvalsPath: store.approvalsPath,
};
return new MRWidgetService(endpoints);
},
checkStatus(cb) {
this.service.checkStatus()
.then(res => res.json())
.then((res) => {
this.mr.setData(res);
this.setFavicon();
if (cb) {
cb.call(null, res);
}
})
.catch(() => {
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
initPolling() {
this.pollingInterval = new gl.SmartInterval({
callback: this.checkStatus,
startingInterval: 10000,
maxInterval: 30000,
hiddenInterval: 120000,
incrementByFactorOf: 5000,
});
},
initDeploymentsPolling() {
this.deploymentsInterval = new gl.SmartInterval({
callback: this.fetchDeployments,
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
},
setFavicon() {
if (this.mr.ciStatusFaviconPath) {
gl.utils.setFavicon(this.mr.ciStatusFaviconPath);
}
},
fetchDeployments() {
this.service.fetchDeployments()
.then(res => res.json())
.then((res) => {
if (res.length) {
this.mr.deployments = res;
}
})
.catch(() => {
new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line
});
},
fetchActionsContent() {
this.service.fetchMergeActionsContent()
.then((res) => {
if (res.body) {
const el = document.createElement('div');
el.innerHTML = res.body;
document.body.appendChild(el);
}
})
.catch(() => {
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
resumePolling() {
this.pollingInterval.resume();
},
stopPolling() {
this.pollingInterval.stopTimer();
},
bindEventHubListeners() {
eventHub.$on('MRWidgetUpdateRequested', (cb) => {
this.checkStatus(cb);
});
// `params` should be an Array contains a Boolean, like `[true]`
// Passing parameter as Boolean didn't work.
eventHub.$on('SetBranchRemoveFlag', (params) => {
this.mr.isRemovingSourceBranch = params[0];
});
eventHub.$on('FailedToMerge', (mergeError) => {
this.mr.state = 'failedToMerge';
this.mr.mergeError = mergeError;
});
eventHub.$on('UpdateWidgetData', (data) => {
this.mr.setData(data);
});
eventHub.$on('FetchActionsContent', () => {
this.fetchActionsContent();
});
eventHub.$on('EnablePolling', () => {
this.resumePolling();
});
eventHub.$on('DisablePolling', () => {
this.stopPolling();
});
},
handleMounted() {
this.checkStatus();
this.setFavicon();
this.initDeploymentsPolling();
},
},
created() {
this.initPolling();
this.bindEventHubListeners();
},
mounted() {
this.handleMounted();
},
components: {
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
'mr-widget-pipeline': WidgetPipeline,
'mr-widget-deployment': WidgetDeployment,
'mr-widget-related-links': WidgetRelatedLinks,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
'mr-widget-locked': LockedState,
'mr-widget-failed-to-merge': FailedToMerge,
'mr-widget-wip': WipState,
'mr-widget-archived': ArchivedState,
'mr-widget-conflicts': ConflictsState,
'mr-widget-nothing-to-merge': NothingToMergeState,
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
'mr-widget-squash-before-merge': SquashBeforeMerge,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
'mr-widget-pipeline-blocked': PipelineBlockedState,
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
'mr-widget-auto-merge-failed': AutoMergeFailed,
},
template: `
<div class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline
v-if="shouldRenderPipelines"
:mr="mr" />
<mr-widget-deployment
v-if="shouldRenderDeployments"
:mr="mr"
:service="service" />
<component
:is="componentName"
:mr="mr"
:service="service" />
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:related-links="mr.relatedLinks" />
<mr-widget-merge-help v-if="shouldRenderMergeHelp" />
</div>
`,
};
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class MRWidgetService {
constructor(endpoints) {
this.mergeResource = Vue.resource(endpoints.mergePath);
this.mergeCheckResource = Vue.resource(endpoints.mergeCheckPath);
this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath);
this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath);
this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`);
this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
}
merge(data) {
return this.mergeResource.save(data);
}
cancelAutomaticMerge() {
return this.cancelAutoMergeResource.save();
}
removeWIP() {
return this.removeWIPResource.save();
}
removeSourceBranch() {
return this.removeSourceBranchResource.delete();
}
fetchDeployments() {
return this.deploymentsResource.get();
}
poll() {
return this.pollResource.get();
}
checkStatus() {
return this.mergeCheckResource.get();
}
fetchMergeActionsContent() {
return this.mergeActionsContentResource.get();
}
static stopEnvironment(url) {
return Vue.http.post(url);
}
static fetchMetrics(metricsUrl) {
return Vue.http.get(`${metricsUrl}.json`);
}
}
export default function deviseState(data) {
if (data.project_archived) {
return 'archived';
} else if (data.branch_missing) {
return 'missingBranch';
} else if (!data.commits_count) {
return 'nothingToMerge';
} else if (this.mergeStatus === 'unchecked') {
return 'checking';
} else if (data.has_conflicts) {
return 'conflicts';
} else if (data.work_in_progress) {
return 'workInProgress';
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
} else if (!this.canMerge) {
return 'notAllowedToMerge';
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return 'pipelineFailed';
} else if (this.hasMergeableDiscussionsState) {
return 'unresolvedDiscussions';
} else if (this.isPipelineBlocked) {
return 'pipelineBlocked';
} else if (this.canBeMerged) {
return 'readyToMerge';
}
return null;
}
import Timeago from 'timeago.js';
import { getStateKey } from '../dependencies';
export default class MergeRequestStore {
constructor(data) {
this.setData(data);
}
setData(data) {
const currentUser = data.current_user;
const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
// EE specific
this.squash = data.squash;
this.title = data.title;
this.targetBranch = data.target_branch;
this.sourceBranch = data.source_branch;
this.mergeStatus = data.merge_status;
this.sha = data.diff_head_sha;
this.commitMessage = data.merge_commit_message;
this.commitMessageWithDescription = data.merge_commit_message_with_description;
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.deployments = this.deployments || data.deployments || [];
if (data.issues_links) {
const links = data.issues_links;
const { closing } = links;
const mentioned = links.mentioned_but_not_closing;
const assignToMe = links.assign_to_closing;
if (closing || mentioned || assignToMe) {
this.relatedLinks = { closing, mentioned, assignToMe };
}
}
this.updatedAt = data.updated_at;
this.mergedAt = MergeRequestStore.getEventDate(data.merge_event);
this.closedAt = MergeRequestStore.getEventDate(data.closed_event);
this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event);
this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event);
this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} });
this.mergeUserId = data.merge_user_id;
this.currentUserId = gon.current_user_id;
this.sourceBranchPath = data.source_branch_path;
this.sourceBranchLink = data.source_branch_with_namespace_link;
this.mergeError = data.merge_error;
this.targetBranchPath = data.target_branch_commits_path;
this.conflictResolutionPath = data.conflict_resolution_path;
this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
this.removeWIPPath = data.remove_wip_path;
this.sourceBranchRemoved = !data.source_branch_exists;
this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
this.mergePath = data.merge_path;
this.statusPath = data.status_path;
this.emailPatchesPath = data.email_patches_path;
this.plainDiffPath = data.plain_diff_path;
this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
this.mergeCheckPath = data.merge_check_path;
this.mergeActionsContentPath = data.commit_change_content_path;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.isOpen = data.state === 'opened' || data.state === 'reopened' || false;
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
this.canMerge = !!data.merge_path;
this.canCreateIssue = currentUser.can_create_issue || false;
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
this.canBeMerged = data.can_be_merged || false;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
this.revertInForkPath = currentUser.revert_in_fork_path;
// CI related
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.hasCI = data.has_ci;
this.ciStatus = data.ci_status;
this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false;
this.pipelineDetailedStatus = pipelineStatus;
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.setState(data);
}
setState(data) {
if (this.isOpen) {
this.state = getStateKey.call(this, data);
} else {
switch (data.state) {
case 'merged':
this.state = 'merged';
break;
case 'closed':
this.state = 'closed';
break;
case 'locked':
this.state = 'locked';
break;
default:
this.state = null;
}
}
}
static getAuthorObject(event) {
if (!event) {
return {};
}
return {
name: event.author.name || '',
username: event.author.username || '',
webUrl: event.author.web_url || '',
avatarUrl: event.author.avatar_url || '',
};
}
static getEventDate(event) {
const timeagoInstance = new Timeago();
if (!event) {
return '';
}
return timeagoInstance.format(event.updated_at);
}
}
const stateToComponentMap = {
merged: 'mr-widget-merged',
closed: 'mr-widget-closed',
locked: 'mr-widget-locked',
conflicts: 'mr-widget-conflicts',
missingBranch: 'mr-widget-missing-branch',
workInProgress: 'mr-widget-wip',
readyToMerge: 'mr-widget-ready-to-merge',
nothingToMerge: 'mr-widget-nothing-to-merge',
notAllowedToMerge: 'mr-widget-not-allowed',
archived: 'mr-widget-archived',
checking: 'mr-widget-checking',
unresolvedDiscussions: 'mr-widget-unresolved-discussions',
pipelineBlocked: 'mr-widget-pipeline-blocked',
pipelineFailed: 'mr-widget-pipeline-failed',
mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
};
const statesToShowHelpWidget = [
'locked',
'conflicts',
'workInProgress',
'readyToMerge',
'checking',
'unresolvedDiscussions',
'pipelineFailed',
'pipelineBlocked',
'autoMergeFailed',
];
export default {
stateToComponentMap,
statesToShowHelpWidget,
};
export default {
name: 'MemoryGraph',
props: {
metrics: { type: Array, required: true },
width: { type: String, required: true },
height: { type: String, required: true },
},
data() {
return {
pathD: '',
pathViewBox: '',
// dotX: '',
// dotY: '',
};
},
mounted() {
const renderData = this.$props.metrics.map(v => v[1]);
const maxMemory = Math.max.apply(null, renderData);
const minMemory = Math.min.apply(null, renderData);
const diff = maxMemory - minMemory;
// const cx = 0;
// const cy = 0;
const lineWidth = renderData.length;
const linePath = renderData.map((y, x) => `${x} ${maxMemory - y}`);
this.pathD = `M ${linePath}`;
this.pathViewBox = `0 0 ${lineWidth} ${diff}`;
},
template: `
<div class="memory-graph-container">
<svg :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
<path :d="pathD" :viewBox="pathViewBox" />
<!--<circle r="0.8" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" /> -->
</svg>
</div>
`,
};
import { statusClassToSvgMap } from '../pipeline_svg_icons';
export default {
name: 'PipelineStatusIcon',
props: {
pipelineStatus: { type: Object, required: true, default: () => ({}) },
},
computed: {
svg() {
return statusClassToSvgMap[this.pipelineStatus.icon];
},
statusClass() {
return `ci-status-icon ci-status-icon-${this.pipelineStatus.group}`;
},
},
template: `
<div :class="statusClass">
<a class="icon-link" :href="pipelineStatus.details_path">
<span v-html="svg" aria-hidden="true"></span>
</a>
</div>
`,
};
import canceledSvg from 'icons/_icon_status_canceled.svg';
import createdSvg from 'icons/_icon_status_created.svg';
import failedSvg from 'icons/_icon_status_failed.svg';
import manualSvg from 'icons/_icon_status_manual.svg';
import pendingSvg from 'icons/_icon_status_pending.svg';
import runningSvg from 'icons/_icon_status_running.svg';
import skippedSvg from 'icons/_icon_status_skipped.svg';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import canceledBorderlessSvg from 'icons/_icon_status_canceled_borderless.svg';
import createdBorderlessSvg from 'icons/_icon_status_created_borderless.svg';
import failedBorderlessSvg from 'icons/_icon_status_failed_borderless.svg';
import manualBorderlessSvg from 'icons/_icon_status_manual_borderless.svg';
import pendingBorderlessSvg from 'icons/_icon_status_pending_borderless.svg';
import runningBorderlessSvg from 'icons/_icon_status_running_borderless.svg';
import skippedBorderlessSvg from 'icons/_icon_status_skipped_borderless.svg';
import successBorderlessSvg from 'icons/_icon_status_success_borderless.svg';
import warningBorderlessSvg from 'icons/_icon_status_warning_borderless.svg';
export const statusClassToSvgMap = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
export const statusClassToBorderlessSvgMap = {
icon_status_canceled: canceledBorderlessSvg,
icon_status_created: createdBorderlessSvg,
icon_status_failed: failedBorderlessSvg,
icon_status_manual: manualBorderlessSvg,
icon_status_pending: pendingBorderlessSvg,
icon_status_running: runningBorderlessSvg,
icon_status_skipped: skippedBorderlessSvg,
icon_status_success: successBorderlessSvg,
icon_status_warning: warningBorderlessSvg,
};
...@@ -47,3 +47,4 @@ ...@@ -47,3 +47,4 @@
@import "framework/emoji-sprites.scss"; @import "framework/emoji-sprites.scss";
@import "framework/icons.scss"; @import "framework/icons.scss";
@import "framework/snippets.scss"; @import "framework/snippets.scss";
@import "framework/memory_graph.scss";
...@@ -92,7 +92,8 @@ hr { ...@@ -92,7 +92,8 @@ hr {
.item-title { font-weight: 600; } .item-title { font-weight: 600; }
/** FLASH message **/ /** FLASH message **/
.author_link { .author_link,
.author-link {
color: $gl-link-color; color: $gl-link-color;
} }
......
.ci-status-icon-success { .ci-status-icon-success,
.ci-status-icon-passed {
color: $green-500; color: $green-500;
svg { svg {
......
.memory-graph-container {
svg {
background: $white-light;
}
path {
fill: none;
stroke: $blue-500;
stroke-width: 1px;
}
circle {
stroke: $blue-700;
fill: $blue-700;
}
}
...@@ -111,6 +111,7 @@ $gl-link-hover-color: $blue-800; ...@@ -111,6 +111,7 @@ $gl-link-hover-color: $blue-800;
$gl-grayish-blue: #7f8fa4; $gl-grayish-blue: #7f8fa4;
$gl-gray: $gl-text-color; $gl-gray: $gl-text-color;
$gl-gray-dark: #313236; $gl-gray-dark: #313236;
$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54; $gl-header-color: #4c4e54;
$gl-header-nav-hover-color: #434343; $gl-header-nav-hover-color: #434343;
$placeholder-text-color: rgba(0, 0, 0, .42); $placeholder-text-color: rgba(0, 0, 0, .42);
......
...@@ -37,12 +37,6 @@ ...@@ -37,12 +37,6 @@
@include btn-red; @include btn-red;
} }
} }
.dropdown-toggle {
.fa {
color: inherit;
}
}
} }
.accept-control { .accept-control {
...@@ -88,13 +82,13 @@ ...@@ -88,13 +82,13 @@
} }
} }
.ci_widget { .ci-widget {
border-bottom: 1px solid $well-inner-border;
color: $gl-text-color; color: $gl-text-color;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
-webkit-align-items: center; -webkit-align-items: center;
align-items: center; align-items: center;
padding: $gl-padding-top $gl-padding 0;
i, i,
svg { svg {
...@@ -115,16 +109,15 @@ ...@@ -115,16 +109,15 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.ci-status-icon > .icon-link > svg { .ci-status-icon > .icon-link svg {
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
} }
.mr-widget-body, .mr-widget-body,
.ci_widget,
.mr-widget-footer { .mr-widget-footer {
padding: 16px; margin: 16px;
} }
.mr-widget-pipeline-graph { .mr-widget-pipeline-graph {
...@@ -166,12 +159,41 @@ ...@@ -166,12 +159,41 @@
.normal { .normal {
color: $gl-text-color; color: $gl-text-color;
font-size: 15px;
}
.capitalize {
text-transform: capitalize;
} }
.js-deployment-link { .js-deployment-link {
display: inline-block; display: inline-block;
} }
.mr-widget-help {
margin: $gl-padding;
color: $ci-skipped-color;
}
.mr-info-list {
&.mr-links {
margin-left: 28px;
}
&.mr-memory-usage {
margin-top: 10px;
margin-bottom: 10px;
}
}
.mr-widget-heading,
.mr-widget-body {
.btn-default.btn-xs {
margin-left: 5px;
}
}
.mr-widget-body { .mr-widget-body {
h4 { h4 {
font-weight: 600; font-weight: 600;
...@@ -182,6 +204,10 @@ ...@@ -182,6 +204,10 @@
&.has-conflicts .fa-exclamation-triangle { &.has-conflicts .fa-exclamation-triangle {
color: $gl-warning; color: $gl-warning;
} }
time {
font-weight: normal;
}
} }
.btn-grouped { .btn-grouped {
...@@ -189,6 +215,80 @@ ...@@ -189,6 +215,80 @@
margin-right: 7px; margin-right: 7px;
} }
label {
font-weight: normal;
}
.spacing {
margin: 0 $gl-padding;
}
.bold {
margin-left: 5px;
font-weight: bold;
color: $gl-gray-light;
}
.state-label {
font-size: 16px;
font-weight: bold;
padding-right: 10px;
}
.danger {
color: $gl-danger;
}
.mr-widget-help {
margin: $gl-padding 0;
}
.with-button {
position: relative;
top: 6px;
margin-bottom: 24px;
}
.dropdown-menu {
li a {
padding: 5px;
}
.merge-opt-icon,
.merge-opt-title {
display: inline-block;
float: left;
}
.merge-opt-icon svg {
height: 15px;
width: 15px;
}
.merge-opt-title {
margin-left: 8px;
}
}
.dropdown-toggle {
.fa {
color: inherit;
}
}
.has-error-message + .has-custom-error {
margin-left: 0;
}
.has-custom-error {
display: inline-block;
margin-left: 70px;
}
.merge-error-text {
margin-left: 70px;
}
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
h4 { h4 {
font-size: 14px; font-size: 14px;
...@@ -220,6 +320,17 @@ ...@@ -220,6 +320,17 @@
margin: 0; margin: 0;
} }
} }
.commit-message-editor {
label {
padding: 0;
}
}
&.mr-state-locked .mr-info-list {
margin-top: 10px;
margin-left: 12px;
}
} }
.mr-widget-footer { .mr-widget-footer {
...@@ -263,6 +374,24 @@ ...@@ -263,6 +374,24 @@
font-size: 90%; font-size: 90%;
margin: 0 3px; margin: 0 3px;
word-break: break-all; word-break: break-all;
&.label-truncated {
position: relative;
display: inline-block;
width: 250px;
margin-bottom: -3px;
white-space: nowrap;
text-overflow: clip;
line-height: 14px;
&::after {
position: absolute;
content: '...';
right: 0;
font-family: $regular_font;
background-color: $gray-light;
}
}
} }
.commits-empty { .commits-empty {
...@@ -343,61 +472,74 @@ ...@@ -343,61 +472,74 @@
} }
} }
.remove-message-pipes { .mr-info-list {
ul { position: relative;
margin: 10px 0 0 12px; margin: 10px 0 $gl-padding 12px;
padding: 0;
list-style: none;
border-left: 2px solid $border-color;
display: inline-block;
}
li { p {
margin: 6px 0;
position: relative; position: relative;
margin: 0; padding-left: 15px;
padding: 0;
display: block;
span { &::before {
margin-left: 15px; content: '';
max-height: 20px; position: absolute;
border-top: 2px solid $border-color;
height: 1px;
top: 8px;
width: 8px;
left: 0;
}
&:last-child {
margin-bottom: 0;
&::before {
top: 14px;
}
} }
} }
li::before { .legend {
content: ''; height: 100%;
width: 2px;
background: $border-color;
position: absolute; position: absolute;
border-top: 2px solid $border-color; top: -5px;
height: 1px; }
top: 8px; }
width: 8px;
.mr-info-list.mr-memory-usage {
.legend {
height: 75%;
} }
li:last-child { p {
float: left;
padding-left: 20px;
&::before { &::before {
top: 18px; top: 13px;
} }
}
span { .memory-graph-container {
display: block; float: left;
position: relative; margin-left: 5px;
top: 5px;
margin-top: 5px;
}
} }
} }
.mr-source-target { .mr-source-target {
background-color: $gray-light; background-color: $gray-light;
line-height: 31px; border-radius: 3px 3px 0 0;
border-style: solid; border-bottom: 1px solid $border-color;
border-width: 1px; padding: 0 $gl-padding;
border-color: $border-color; margin-bottom: 6px;
border-top-right-radius: 3px; line-height: 44px;
border-top-left-radius: 3px;
border-bottom: none; .dropdown-toggle .fa {
padding: 16px; color: $gl-text-color;
margin-bottom: -1px; }
} }
.panel-new-merge-request { .panel-new-merge-request {
...@@ -590,10 +732,6 @@ ...@@ -590,10 +732,6 @@
#merge-request-widget-app .loading { #merge-request-widget-app .loading {
padding-top: 5px; padding-top: 5px;
}
#merge-request-widget-app .loading,
.approvals-components {
border-top: 1px solid $well-inner-border; border-top: 1px solid $well-inner-border;
} }
...@@ -601,15 +739,20 @@ ...@@ -601,15 +739,20 @@
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
text-align: center; text-align: center;
} }
.approve-btn {
margin-top: 10px;
}
} }
.approvals-footer { .approvals-footer {
display: flex; display: flex;
.legend {
height: 16px;
top: 0;
}
.approvers-prefix::before {
top: 10px;
}
// vertically centers all children // vertically centers all children
> span { > span {
align-self: center; align-self: center;
...@@ -626,14 +769,16 @@ ...@@ -626,14 +769,16 @@
} }
} }
.approvers-prefix { .approvers-prefix,
.approvers-list {
margin-right: 5px; margin-right: 5px;
float: left;
} }
.unapprove-btn-wrap { .approvers-list img {
border-left: 1px solid $gray-darker; width: 18px;
padding-left: 5px; height: 18px;
margin-left: 10px; margin-top: 3px;
} }
.unapprove-btn { .unapprove-btn {
...@@ -677,3 +822,20 @@ ...@@ -677,3 +822,20 @@
margin-right: 3px; margin-right: 3px;
} }
} }
.mr-memory-usage {
p.usage-info-loading {
margin-bottom: 6px;
.usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
}
}
@media (max-width: $screen-md-min) {
.mr-info-list.mr-memory-usage .legend {
height: 80%;
}
}
}
...@@ -104,7 +104,10 @@ class ApplicationController < ActionController::Base ...@@ -104,7 +104,10 @@ class ApplicationController < ActionController::Base
end end
def access_denied! def access_denied!
render "errors/access_denied", layout: "errors", status: 404 respond_to do |format|
format.json { head :not_found }
format.any { render "errors/access_denied", layout: "errors", status: 404 }
end
end end
def git_not_found! def git_not_found!
......
...@@ -79,7 +79,9 @@ class Projects::BranchesController < Projects::ApplicationController ...@@ -79,7 +79,9 @@ class Projects::BranchesController < Projects::ApplicationController
redirect_to namespace_project_branches_path(@project.namespace, redirect_to namespace_project_branches_path(@project.namespace,
@project), status: 303 @project), status: 303
end end
format.js { render nothing: true, status: status[:return_code] } format.js { render nothing: true, status: status[:return_code] }
format.json { render json: { message: status[:message] }, status: status[:return_code] }
end end
end end
......
...@@ -39,7 +39,7 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -39,7 +39,7 @@ class Projects::CommitController < Projects::ApplicationController
Gitlab::PollingInterval.set_header(response, interval: 10_000) Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: PipelineSerializer render json: PipelineSerializer
.new(project: @project, user: @current_user) .new(project: @project, current_user: @current_user)
.represent(@pipelines) .represent(@pipelines)
end end
end end
......
...@@ -10,8 +10,22 @@ class Projects::DeploymentsController < Projects::ApplicationController ...@@ -10,8 +10,22 @@ class Projects::DeploymentsController < Projects::ApplicationController
.represent_concise(deployments) } .represent_concise(deployments) }
end end
def metrics
@metrics = deployment.metrics(1.hour)
if @metrics&.any?
render json: @metrics, status: :ok
else
head :no_content
end
end
private private
def deployment
@deployment ||= environment.deployments.find_by(iid: params[:id])
end
def environment def environment
@environment ||= project.environments.find(params[:environment_id]) @environment ||= project.environments.find(params[:environment_id])
end end
......
...@@ -82,10 +82,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -82,10 +82,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
stop_action = @environment.stop_with_action!(current_user) stop_action = @environment.stop_with_action!(current_user)
if stop_action action_or_env_url =
redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) if stop_action
else polymorphic_url([project.namespace.becomes(Namespace), project, stop_action])
redirect_to namespace_project_environment_path(project.namespace, project, @environment) else
namespace_project_environment_url(project.namespace, project, @environment)
end
respond_to do |format|
format.html { redirect_to action_or_env_url }
format.json { render json: { redirect_url: action_or_env_url } }
end end
end end
......
...@@ -35,7 +35,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -35,7 +35,7 @@ class Projects::PipelinesController < Projects::ApplicationController
render json: { render json: {
pipelines: PipelineSerializer pipelines: PipelineSerializer
.new(project: @project, user: @current_user) .new(project: @project, current_user: @current_user)
.with_pagination(request, response) .with_pagination(request, response)
.represent(@pipelines), .represent(@pipelines),
count: { count: {
...@@ -82,7 +82,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -82,7 +82,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def status def status
render json: PipelineSerializer render json: PipelineSerializer
.new(project: @project, user: @current_user) .new(project: @project, current_user: @current_user)
.represent_status(@pipeline) .represent_status(@pipeline)
end end
......
...@@ -37,7 +37,10 @@ module IssuablesHelper ...@@ -37,7 +37,10 @@ module IssuablesHelper
when Issue when Issue
IssueSerializer.new.represent(issuable).to_json IssueSerializer.new.represent(issuable).to_json
when MergeRequest when MergeRequest
MergeRequestSerializer.new.represent(issuable).to_json MergeRequestSerializer
.new(current_user: current_user, project: issuable.project)
.represent(issuable)
.to_json
end end
end end
......
...@@ -19,14 +19,6 @@ module MergeRequestsHelper ...@@ -19,14 +19,6 @@ module MergeRequestsHelper
} }
end end
def mr_widget_refresh_url(mr)
if mr && mr.target_project
merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr)
else
''
end
end
def mr_css_classes(mr) def mr_css_classes(mr)
classes = "merge-request" classes = "merge-request"
classes << " closed" if mr.closed? classes << " closed" if mr.closed?
...@@ -55,23 +47,6 @@ module MergeRequestsHelper ...@@ -55,23 +47,6 @@ module MergeRequestsHelper
end end
end end
def issues_sentence(issues)
# Issuable sorter will sort local issues, then issues from the same
# namespace, then all other issues.
issues = Gitlab::IssuableSorter.sort(@project, issues).map do |issue|
issue.to_reference(@project)
end
issues.to_sentence
end
def mr_closes_issues
@mr_closes_issues ||= @merge_request.closes_issues(current_user)
end
def mr_issues_mentioned_but_not_closing
@mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user)
end
def mr_change_branches_path(merge_request) def mr_change_branches_path(merge_request)
new_namespace_project_merge_request_path( new_namespace_project_merge_request_path(
@project.namespace, @project, @project.namespace, @project,
...@@ -99,33 +74,29 @@ module MergeRequestsHelper ...@@ -99,33 +74,29 @@ module MergeRequestsHelper
end end
end end
def mr_assign_issues_link # This may be able to be removed with associated specs
issues = MergeRequests::AssignIssuesService.new(@project, def render_require_section(merge_request)
current_user, str = if merge_request.approvals_left == 1
merge_request: @merge_request, "Requires one more approval"
closes_issues: mr_closes_issues else
).assignable_issues "Requires #{merge_request.approvals_left} more approvals"
path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) end
if issues.present?
pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" if merge_request.approvers_left.any?
link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post more_approvals = merge_request.approvals_left - merge_request.approvers_left.count
approvers_names = merge_request.approvers_left.map(&:name)
str <<
if more_approvals > 0
" (from #{render_items_list(approvers_names + ["#{more_approvals} more"])})"
elsif more_approvals < 0
" (from #{render_items_list(approvers_names, "or")})"
else
" (from #{render_items_list(approvers_names)})"
end
end end
end
def source_branch_with_namespace(merge_request) str
namespace = merge_request.source_project_namespace
branch = merge_request.source_branch
if merge_request.source_branch_exists?
namespace = link_to(namespace, project_path(merge_request.source_project))
branch = link_to(branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch))
end
if merge_request.for_fork?
namespace + ":" + branch
else
branch
end
end end
def format_mr_branch_names(merge_request) def format_mr_branch_names(merge_request)
......
...@@ -99,6 +99,21 @@ class Deployment < ActiveRecord::Base ...@@ -99,6 +99,21 @@ class Deployment < ActiveRecord::Base
created_at.to_time.in_time_zone.to_s(:medium) created_at.to_time.in_time_zone.to_s(:medium)
end end
def has_metrics?
project.monitoring_service.present?
end
def metrics(timeframe)
return {} unless has_metrics?
half_timeframe = timeframe / 2
timeframe_start = created_at - half_timeframe
timeframe_end = created_at + half_timeframe
metrics = project.monitoring_service.metrics(environment, timeframe_start: timeframe_start, timeframe_end: timeframe_end)
metrics&.merge(deployment_time: created_at.to_i) || {}
end
private private
def ref_path def ref_path
......
...@@ -930,7 +930,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -930,7 +930,7 @@ class MergeRequest < ActiveRecord::Base
end end
def can_be_cherry_picked? def can_be_cherry_picked?
merge_commit merge_commit.present?
end end
def has_complete_diff_refs? def has_complete_diff_refs?
...@@ -974,6 +974,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -974,6 +974,8 @@ class MergeRequest < ActiveRecord::Base
end end
def conflicts_can_be_resolved_by?(user) def conflicts_can_be_resolved_by?(user)
return false unless source_project
access = ::Gitlab::UserAccess.new(user, project: source_project) access = ::Gitlab::UserAccess.new(user, project: source_project)
access.can_push_to_branch?(source_branch) access.can_push_to_branch?(source_branch)
end end
......
...@@ -10,7 +10,7 @@ class MonitoringService < Service ...@@ -10,7 +10,7 @@ class MonitoringService < Service
end end
# Environments have a number of metrics # Environments have a number of metrics
def metrics(environment) def metrics(environment, timeframe_start: nil, timeframe_end: nil)
raise NotImplementedError raise NotImplementedError
end end
end end
class PrometheusService < MonitoringService class PrometheusService < MonitoringService
include ReactiveCaching include ReactiveService
self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute self.reactive_cache_lifetime = 1.minute
...@@ -64,16 +63,22 @@ class PrometheusService < MonitoringService ...@@ -64,16 +63,22 @@ class PrometheusService < MonitoringService
{ success: false, result: err } { success: false, result: err }
end end
def metrics(environment) def metrics(environment, timeframe_start: nil, timeframe_end: nil)
with_reactive_cache(environment.slug) do |data| with_reactive_cache(environment.slug, timeframe_start, timeframe_end) do |data|
data data
end end
end end
# Cache metrics for specific environment # Cache metrics for specific environment
def calculate_reactive_cache(environment_slug) def calculate_reactive_cache(environment_slug, timeframe_start, timeframe_end)
return unless active? && project && !project.pending_delete? return unless active? && project && !project.pending_delete?
timeframe_start = Time.parse(timeframe_start) if timeframe_start
timeframe_end = Time.parse(timeframe_end) if timeframe_end
timeframe_start ||= 8.hours.ago
timeframe_end ||= Time.now
memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024}
cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100}
...@@ -81,11 +86,13 @@ class PrometheusService < MonitoringService ...@@ -81,11 +86,13 @@ class PrometheusService < MonitoringService
success: true, success: true,
metrics: { metrics: {
# Average Memory used in MB # Average Memory used in MB
memory_values: client.query_range(memory_query, start: 8.hours.ago), memory_values: client.query_range(memory_query, start: timeframe_start, stop: timeframe_end),
memory_current: client.query(memory_query), memory_current: client.query(memory_query, time: timeframe_end),
memory_previous: client.query(memory_query, time: timeframe_start),
# Average CPU Utilization # Average CPU Utilization
cpu_values: client.query_range(cpu_query, start: 8.hours.ago), cpu_values: client.query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
cpu_current: client.query(cpu_query) cpu_current: client.query(cpu_query, time: timeframe_end),
cpu_previous: client.query(cpu_query, time: timeframe_start)
}, },
last_update: Time.now.utc last_update: Time.now.utc
} }
......
...@@ -1034,6 +1034,15 @@ class User < ActiveRecord::Base ...@@ -1034,6 +1034,15 @@ class User < ActiveRecord::Base
devise_mailer.send(notification, self, *args).deliver_later devise_mailer.send(notification, self, *args).deliver_later
end end
# This works around a bug in Devise 4.2.0 that erroneously causes a user to
# be considered active in MySQL specs due to a sub-second comparison
# issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709
def confirmation_period_valid?
return false if self.class.allow_unconfirmed_access_for == 0.days
super
end
def ensure_external_user_rights def ensure_external_user_rights
return unless external? return unless external?
......
class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
include GitlabRoutingHelper
include MarkupHelper
include TreeHelper
presents :merge_request
def ci_status
if pipeline
status = pipeline.status
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
status || "preparing"
else
ci_service = source_project.try(:ci_service)
ci_service&.commit_status(diff_head_sha, source_branch)
end
end
def cancel_merge_when_pipeline_succeeds_path
if can_cancel_merge_when_pipeline_succeeds?(current_user)
cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(
project.namespace,
project,
merge_request)
end
end
def create_issue_to_resolve_discussions_path
if can?(current_user, :create_issue, project) && project.issues_enabled?
new_namespace_project_issue_path(project.namespace,
project,
merge_request_to_resolve_discussions_of: iid)
end
end
def remove_wip_path
if can?(current_user, :update_merge_request, merge_request.project)
remove_wip_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
end
def merge_path
if can_be_merged_by?(current_user)
merge_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
end
def revert_in_fork_path
if user_can_fork_project? && can_be_reverted?(current_user)
continue_params = {
to: merge_request_path(merge_request),
notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
notice_now: edit_in_new_fork_notice_now
}
namespace_project_forks_path(merge_request.project.namespace, merge_request.project,
namespace_key: current_user.namespace.id,
continue: continue_params)
end
end
def cherry_pick_in_fork_path
if user_can_fork_project? && can_be_cherry_picked?
continue_params = {
to: merge_request_path(merge_request),
notice: "#{edit_in_new_fork_notice} Try to revert this commit again.",
notice_now: edit_in_new_fork_notice_now
}
namespace_project_forks_path(project.namespace, project,
namespace_key: current_user.namespace.id,
continue: continue_params)
end
end
def conflict_resolution_path
if conflicts_can_be_resolved_in_ui? && conflicts_can_be_resolved_by?(current_user)
conflicts_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
end
def rebase_path
if !rebase_in_progress? && should_be_rebased? && user_can_push_to_source_branch?
rebase_namespace_project_merge_request_path(project.namespace,
project,
merge_request)
end
end
def target_branch_commits_path
if target_branch_exists?
namespace_project_commits_path(project.namespace, project, target_branch)
end
end
def source_branch_path
if source_branch_exists?
namespace_project_branch_path(source_project.namespace, source_project, source_branch)
end
end
def approvals_path
if requires_approve?
approvals_namespace_project_merge_request_path(project.namespace,
project,
merge_request)
end
end
def source_branch_with_namespace_link
namespace = source_project_namespace
branch = source_branch
if source_branch_exists?
namespace = link_to(namespace, project_path(source_project))
branch = link_to(branch, namespace_project_commits_path(source_project.namespace, source_project, source_branch))
end
if for_fork?
namespace + ":" + branch
else
branch
end
end
def closing_issues_links
markdown issues_sentence(project, closing_issues), pipeline: :gfm, author: author, project: project
end
def mentioned_issues_links
mentioned_issues = issues_mentioned_but_not_closing(current_user)
markdown issues_sentence(project, mentioned_issues), pipeline: :gfm, author: author, project: project
end
def assign_to_closing_issues_link
issues = MergeRequests::AssignIssuesService.new(project,
current_user,
merge_request: merge_request,
closes_issues: closing_issues
).assignable_issues
path = assign_related_issues_namespace_project_merge_request_path(project.namespace, project, merge_request)
if issues.present?
pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
end
end
def can_revert_on_current_merge_request?
user_can_collaborate_with_project? && can_be_reverted?(current_user)
end
def can_cherry_pick_on_current_merge_request?
user_can_collaborate_with_project? && can_be_cherry_picked?
end
def can_push_to_source_branch?
source_branch_exists? && user_can_push_to_source_branch?
end
private
def closing_issues
@closing_issues ||= closes_issues(current_user)
end
def pipeline
@pipeline ||= head_pipeline
end
def issues_sentence(project, issues)
# Sorting based on the `#123` or `group/project#123` reference will sort
# local issues first.
issues.map do |issue|
issue.to_reference(project)
end.sort.to_sentence
end
def user_can_push_to_source_branch?
::Gitlab::UserAccess
.new(current_user, project: source_project)
.can_push_to_branch?(source_branch)
end
def user_can_collaborate_with_project?
can?(current_user, :push_code, project) ||
(current_user && current_user.already_forked?(project))
end
def user_can_fork_project?
can?(current_user, :fork_project, project)
end
end
...@@ -3,8 +3,10 @@ class BaseSerializer ...@@ -3,8 +3,10 @@ class BaseSerializer
@request = EntityRequest.new(parameters) @request = EntityRequest.new(parameters)
end end
def represent(resource, opts = {}) def represent(resource, opts = {}, entity_class = nil)
self.class.entity_class entity_class = entity_class || self.class.entity_class
entity_class
.represent(resource, opts.merge(request: @request)) .represent(resource, opts.merge(request: @request))
.as_json .as_json
end end
......
class EventEntity < Grape::Entity
expose :author, using: UserEntity
expose :updated_at
end
class MergeRequestBasicEntity < Grape::Entity
expose :merge_status
expose :merge_error
expose :state
expose :source_branch_exists?, as: :source_branch_exists
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
expose :rebase_in_progress?, as: :rebase_in_progress
end
class MergeRequestBasicSerializer < BaseSerializer
entity MergeRequestBasicEntity
end
class MergeRequestEntity < IssuableEntity class MergeRequestEntity < IssuableEntity
<<<<<<< HEAD
expose :approvals_before_merge expose :approvals_before_merge
expose :assignee_id expose :assignee_id
=======
include RequestAwareEntity
>>>>>>> a6d35c27cac69f7edaaf64a0df863f1a3b557db1
expose :in_progress_merge_commit_sha expose :in_progress_merge_commit_sha
expose :locked_at expose :locked_at
expose :merge_commit_sha expose :merge_commit_sha
...@@ -9,10 +14,198 @@ class MergeRequestEntity < IssuableEntity ...@@ -9,10 +14,198 @@ class MergeRequestEntity < IssuableEntity
expose :merge_status expose :merge_status
expose :merge_user_id expose :merge_user_id
expose :merge_when_pipeline_succeeds expose :merge_when_pipeline_succeeds
expose :rebase_commit_sha
expose :rebase_in_progress?, if: { type: :full }
expose :source_branch expose :source_branch
expose :source_project_id expose :source_project_id
expose :target_branch expose :target_branch
expose :target_project_id expose :target_project_id
# EE-specific
expose :approvals_before_merge
expose :squash
expose :rebase_commit_sha
expose :rebase_in_progress?, as: :rebase_in_progress
expose :should_be_rebased?, as: :should_be_rebased
expose :approved?, as: :approved
expose :ff_only_enabled do |merge_request|
merge_request.project.merge_requests_ff_only_enabled
end
expose :can_push_to_source_branch do |merge_request|
presenter(merge_request).can_push_to_source_branch?
end
expose :rebase_path do |merge_request|
presenter(merge_request).rebase_path
end
expose :approvals_path do |merge_request|
presenter(merge_request).approvals_path
end
# Events
expose :merge_event, using: EventEntity
expose :closed_event, using: EventEntity
# User entities
expose :author, using: UserEntity
expose :merge_user, using: UserEntity
# Diff sha's
expose :diff_head_sha do |merge_request|
merge_request.diff_head_sha if merge_request.diff_head_commit
end
expose :merge_commit_sha
expose :merge_commit_message
expose :head_pipeline, with: PipelineEntity, as: :pipeline
# Booleans
expose :work_in_progress?, as: :work_in_progress
expose :source_branch_exists?, as: :source_branch_exists
expose :mergeable_discussions_state?, as: :mergeable_discussions_state
expose :branch_missing?, as: :branch_missing
expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
expose :can_be_merged?, as: :can_be_merged
expose :project_archived do |merge_request|
merge_request.project.archived?
end
expose :only_allow_merge_if_pipeline_succeeds do |merge_request|
merge_request.project.only_allow_merge_if_pipeline_succeeds?
end
# CI related
expose :has_ci?, as: :has_ci
expose :ci_status do |merge_request|
presenter(merge_request).ci_status
end
expose :issues_links do
expose :assign_to_closing do |merge_request|
presenter(merge_request).assign_to_closing_issues_link
end
expose :closing do |merge_request|
presenter(merge_request).closing_issues_links
end
expose :mentioned_but_not_closing do |merge_request|
presenter(merge_request).mentioned_issues_links
end
end
expose :source_branch_with_namespace_link do |merge_request|
presenter(merge_request).source_branch_with_namespace_link
end
expose :source_branch_path do |merge_request|
presenter(merge_request).source_branch_path
end
expose :current_user do
expose :can_remove_source_branch do |merge_request|
merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
expose :can_revert_on_current_merge_request do |merge_request|
presenter(merge_request).can_revert_on_current_merge_request?
end
expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request?
end
end
# Paths
#
expose :target_branch_commits_path do |merge_request|
presenter(merge_request).target_branch_commits_path
end
expose :conflict_resolution_path do |merge_request|
presenter(merge_request).conflict_resolution_path
end
expose :remove_wip_path do |merge_request|
presenter(merge_request).remove_wip_path
end
expose :cancel_merge_when_pipeline_succeeds_path do |merge_request|
presenter(merge_request).cancel_merge_when_pipeline_succeeds_path
end
expose :create_issue_to_resolve_discussions_path do |merge_request|
presenter(merge_request).create_issue_to_resolve_discussions_path
end
expose :merge_path do |merge_request|
presenter(merge_request).merge_path
end
expose :cherry_pick_in_fork_path do |merge_request|
presenter(merge_request).cherry_pick_in_fork_path
end
expose :revert_in_fork_path do |merge_request|
presenter(merge_request).revert_in_fork_path
end
expose :email_patches_path do |merge_request|
namespace_project_merge_request_path(merge_request.project.namespace,
merge_request.project,
merge_request,
format: :patch)
end
expose :plain_diff_path do |merge_request|
namespace_project_merge_request_path(merge_request.project.namespace,
merge_request.project,
merge_request,
format: :diff)
end
expose :status_path do |merge_request|
namespace_project_merge_request_path(merge_request.target_project.namespace,
merge_request.target_project,
merge_request,
format: :json)
end
expose :merge_check_path do |merge_request|
merge_check_namespace_project_merge_request_path(merge_request.project.namespace,
merge_request.project,
merge_request)
end
expose :ci_environments_status_path do |merge_request|
ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace,
merge_request.project,
merge_request)
end
expose :merge_commit_message_with_description do |merge_request|
merge_request.merge_commit_message(include_description: true)
end
expose :diverged_commits_count do |merge_request|
if merge_request.open? && merge_request.diverged_from_target_branch?
merge_request.diverged_commits_count
else
0
end
end
expose :commit_change_content_path do |merge_request|
commit_change_content_namespace_project_merge_request_path(merge_request.project.namespace,
merge_request.project,
merge_request)
end
private
delegate :current_user, to: :request
def presenter(merge_request)
@presenters ||= {}
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user)
end
end end
class MergeRequestSerializer < BaseSerializer class MergeRequestSerializer < BaseSerializer
entity MergeRequestEntity # This overrided method takes care of which entity should be used
# to serialize the `merge_request` based on `basic` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope.
def represent(merge_request, opts = {})
entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity
super(merge_request, opts, entity)
end
end end
...@@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity ...@@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity
expose :id expose :id
expose :user, using: UserEntity expose :user, using: UserEntity
expose :active?, as: :active
expose :coverage
expose :path do |pipeline| expose :path do |pipeline|
namespace_project_pipeline_path( namespace_project_pipeline_path(
...@@ -69,16 +71,16 @@ class PipelineEntity < Grape::Entity ...@@ -69,16 +71,16 @@ class PipelineEntity < Grape::Entity
alias_method :pipeline, :object alias_method :pipeline, :object
def can_retry? def can_retry?
can?(request.user, :update_pipeline, pipeline) && can?(request.current_user, :update_pipeline, pipeline) &&
pipeline.retryable? pipeline.retryable?
end end
def can_cancel? def can_cancel?
can?(request.user, :update_pipeline, pipeline) && can?(request.current_user, :update_pipeline, pipeline) &&
pipeline.cancelable? pipeline.cancelable?
end end
def detailed_status def detailed_status
pipeline.detailed_status(request.user) pipeline.detailed_status(request.current_user)
end end
end end
...@@ -37,4 +37,11 @@ class PipelineSerializer < BaseSerializer ...@@ -37,4 +37,11 @@ class PipelineSerializer < BaseSerializer
data = represent(resource, { only: [{ details: [:status] }] }) data = represent(resource, { only: [{ details: [:status] }] })
data.dig(:details, :status) || {} data.dig(:details, :status) || {}
end end
def represent_stages(resource)
return {} unless resource.present?
data = represent(resource, { only: [{ details: [:stages] }] })
data.dig(:details, :stages) || []
end
end end
...@@ -33,6 +33,6 @@ class StageEntity < Grape::Entity ...@@ -33,6 +33,6 @@ class StageEntity < Grape::Entity
alias_method :stage, :object alias_method :stage, :object
def detailed_status def detailed_status
stage.detailed_status(request.user) stage.detailed_status(request.current_user)
end end
end end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment