diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-05-09 14:09:07 +0100 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2017-05-09 14:09:07 +0100 |
commit | 0c3abe3ef92fe4d982b780397e8ace37a51aca45 (patch) | |
tree | 4781ef63fa315d5a2a0edff6340d38e84db8847b /app | |
parent | d3b124e473e051d424070b93c1e800fab7c08656 (diff) | |
parent | 387b44103c168d9a1b82997101deb60c61b6aaf1 (diff) | |
download | gitlab-ce-0c3abe3ef92fe4d982b780397e8ace37a51aca45.tar.gz |
Merge branch 'master' into 31053-pipeline-ux31053-pipeline-ux
* master:
Fallback to default pattern when note does not belong to project
Merge request widget redesign
Fixed focused test in notes spec
Fixed UP arrow key not editing last comment in discussion
Fix skipped manual actions issue in pipeline processing
Fix notify_only_default_branch check for Slack service
Diffstat (limited to 'app')
111 files changed, 3075 insertions, 829 deletions
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 92f6fd654b3..9d51fb53eb2 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); } else { new Flash(errorFlashMsg); } diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 4ea6ba8a73d..ba4f6d36fcb 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -49,6 +49,7 @@ class ResolveServiceClass { discussion.resolveAllNotes(resolved_by); } + gl.mrWidget.checkStatus(); discussion.updateHeadline(data); } else { throw new Error('An error occurred when trying to resolve discussion.'); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index bf802056d36..abb871c3af0 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -10,7 +10,6 @@ /* global IssuableForm */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global MergedButtons */ /* global Commit */ /* global NotificationsForm */ /* global TreeView */ @@ -216,15 +215,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new gl.Diff(); shortcut_handler = new ShortcutsIssuable(true); new ZenMode(); - new MergedButtons(); - break; - case 'projects:merge_requests:commits': - new MergedButtons(); break; case "projects:merge_requests:diffs": new gl.Diff(); new ZenMode(); - new MergedButtons(); break; case 'dashboard:activity': new gl.Activities(); diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js new file mode 100644 index 00000000000..25ca98afbe7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/simple_poll.js @@ -0,0 +1,15 @@ +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); + }); +}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b0d5fc92e3..a07aa047293 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -123,8 +123,6 @@ import './member_expiration_date'; import './members'; import './merge_request'; import './merge_request_tabs'; -import './merge_request_widget'; -import './merged_buttons'; import './milestone'; import './milestone_select'; import './mini_pipeline_graph_dropdown'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 5e01aacf2ba..ed342b9990f 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -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; })(); }).call(window); diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js deleted file mode 100644 index 21d7c3e168e..00000000000 --- a/app/assets/javascripts/merge_request_widget/ci_bundle.js +++ /dev/null @@ -1,53 +0,0 @@ -/* 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 { - setTimeout(() => merge_request_widget.getMergeStatus(), 200); - } - }); -})(); diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js deleted file mode 100644 index 7b0997c6520..00000000000 --- a/app/assets/javascripts/merged_buttons.js +++ /dev/null @@ -1,47 +0,0 @@ -/* 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); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 55391ebc089..194c29f4710 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -175,7 +175,7 @@ const normalizeNewlines = function(str) { if ($textarea.val() !== '') { return; } - myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes')); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -276,7 +276,7 @@ const normalizeNewlines = function(str) { var votesBlock; if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { - $.get(mrRefreshWidgetUrl); + Notes.checkMergeRequestStatus(); } if ('emoji_award' in noteEntity.commands_changes) { @@ -424,6 +424,7 @@ const normalizeNewlines = function(str) { } gl.utils.localTimeAgo($('.js-timeago'), false); + Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); }; @@ -769,7 +770,8 @@ const normalizeNewlines = function(str) { } }; })(this)); - // Decrement the "Discussions" counter only once + + Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); }; @@ -1115,6 +1117,12 @@ const normalizeNewlines = function(str) { return $form; }; + Notes.checkMergeRequestStatus = function() { + if (gl.utils.getPagePath(1) === 'merge_requests') { + gl.mrWidget.checkStatus(); + } + }; + Notes.animateAppendNote = function(noteHtml, $notesList) { const $note = $(noteHtml); diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js new file mode 100644 index 00000000000..034e8d3280e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.js @@ -0,0 +1,104 @@ +/* global Flash */ +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; + +export default { + data() { + return { + builds: '', + spinner: '<span class="fa fa-spinner fa-spin"></span>', + }; + }, + + props: { + stage: { + type: Object, + required: true, + }, + }, + + 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; + }, () => { + 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 borderlessStatusIconEntityMap[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" + ref="button" + :aria-label="stage.title"> + <span v-html="svgHTML" aria-hidden="true"></span> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js new file mode 100644 index 00000000000..a01cb8cc202 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js @@ -0,0 +1,23 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js new file mode 100644 index 00000000000..6d2ed5fda64 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js @@ -0,0 +1,27 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js new file mode 100644 index 00000000000..630e80a7408 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,118 @@ +/* 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js new file mode 100644 index 00000000000..4a1fd881169 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -0,0 +1,98 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js new file mode 100644 index 00000000000..395cc9e91fc --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -0,0 +1,109 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js new file mode 100644 index 00000000000..2fecebce7a0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js @@ -0,0 +1,23 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js new file mode 100644 index 00000000000..801b9fb1ba1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -0,0 +1,76 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js new file mode 100644 index 00000000000..205804670fa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js @@ -0,0 +1,42 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js new file mode 100644 index 00000000000..c7f25a1697c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js @@ -0,0 +1,16 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js new file mode 100644 index 00000000000..fcccb17f58d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js @@ -0,0 +1,22 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js new file mode 100644 index 00000000000..8515b54e62d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -0,0 +1,19 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js new file mode 100644 index 00000000000..7e66441e5ff --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js @@ -0,0 +1,30 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js new file mode 100644 index 00000000000..36596c6f37e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -0,0 +1,39 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js new file mode 100644 index 00000000000..600b4d42e3d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -0,0 +1,76 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js new file mode 100644 index 00000000000..e3c27dfb76d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js @@ -0,0 +1,24 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js new file mode 100644 index 00000000000..bcdbedcd46b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,116 @@ +/* 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js new file mode 100644 index 00000000000..c7d32d18141 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,130 @@ +/* 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js new file mode 100644 index 00000000000..328382485f6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -0,0 +1,34 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js new file mode 100644 index 00000000000..07169b349be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js @@ -0,0 +1,17 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js new file mode 100644 index 00000000000..8c4535f1337 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -0,0 +1,17 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js new file mode 100644 index 00000000000..31c53b679ed --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js @@ -0,0 +1,16 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js new file mode 100644 index 00000000000..002820123ca --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -0,0 +1,16 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js new file mode 100644 index 00000000000..ebcc03e531b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,309 @@ +/* global Flash */ + +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; +import simplePoll from '~/lib/utils/simple_poll'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetReadyToMerge', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + removeSourceBranch: true, + mergeWhenBuildSucceeds: false, + useCommitMessageWithDescription: false, + setToMergeWhenPipelineSucceeds: false, + showCommitMessageEditor: false, + isMakingRequest: false, + isMergingImmediately: false, + commitMessage: this.mr.commitMessage, + successSvg, + warningSvg, + }; + }, + computed: { + commitMessageLinkTitle() { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + return this.useCommitMessageWithDescription ? withoutDesc : withDesc; + }, + mergeButtonClass() { + const defaultClass = 'btn btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; + + if (hasCI && !ciStatus) { + return failedClass; + } else if (!pipeline) { + return defaultClass; + } else if (isPipelineActive) { + return inActionClass; + } else if (isPipelineFailed) { + return failedClass; + } + + return defaultClass; + }, + mergeButtonText() { + if (this.isMergingImmediately) { + return 'Merge in progress'; + } else if (this.mr.isPipelineActive) { + return 'Merge when pipeline succeeds'; + } + + return 'Merge'; + }, + shouldShowMergeOptionsDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, + isMergeButtonDisabled() { + const { commitMessage } = this; + return Boolean(!commitMessage.length + || !this.isMergeAllowed() + || this.isMakingRequest + || this.mr.preventMerge); + }, + shouldShowSquashBeforeMerge() { + const { commitsCount, enableSquashBeforeMerge } = this.mr; + return enableSquashBeforeMerge && commitsCount > 1; + }, + }, + methods: { + isMergeAllowed() { + return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed); + }, + updateCommitMessage() { + const cmwd = this.mr.commitMessageWithDescription; + this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; + this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; + }, + toggleCommitMessageEditor() { + this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { + // TODO: Remove no-param-reassign + if (mergeWhenBuildSucceeds === undefined) { + mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign + } else if (mergeImmediately) { + this.isMergingImmediately = true; + } + + this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + + const options = { + sha: this.mr.sha, + commit_message: this.commitMessage, + merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + should_remove_source_branch: this.removeSourceBranch === true, + }; + + // Only truthy in EE extension of this component + if (this.setAdditionalParams) { + this.setAdditionalParams(options); + } + + this.isMakingRequest = true; + this.service.merge(options) + .then(res => res.json()) + .then((res) => { + const hasError = res.status === 'failed' || res.status === 'hook_validation_error'; + + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } else if (res.status === 'success') { + this.initiateMergePolling(); + } else if (hasError) { + eventHub.$emit('FailedToMerge', res.merge_error); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initiateMergePolling() { + simplePoll((continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }); + }, + handleMergePolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + if (res.state === 'merged') { + // If state is merged we should update the widget and stop the polling + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('FetchActionsContent'); + if (window.mergeRequest) { + window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged'); + window.mergeRequest.decreaseCounter(); + } + stopPolling(); + + // If user checked remove source branch and we didn't remove the branch yet + // we should start another polling for source branch remove process + if (this.removeSourceBranch && res.source_branch_exists) { + this.initiateRemoveSourceBranchPolling(); + } + } else if (res.merge_error) { + eventHub.$emit('FailedToMerge', res.merge_error); + stopPolling(); + } else { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + }); + }, + initiateRemoveSourceBranchPolling() { + // We need to show source branch is being removed spinner in another component + eventHub.$emit('SetBranchRemoveFlag', [true]); + + simplePoll((continuePolling, stopPolling) => { + this.handleRemoveBranchPolling(continuePolling, stopPolling); + }); + }, + handleRemoveBranchPolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + // If source branch exists then we should continue polling + // because removing a source branch is a background task and takes time + if (res.source_branch_exists) { + continuePolling(); + } else { + // Branch is removed. Update widget, stop polling and hide the spinner + eventHub.$emit('MRWidgetUpdateRequested', () => { + eventHub.$emit('SetBranchRemoveFlag', [false]); + }); + stopPolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <span class="btn-group"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + {{mergeButtonText}} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-info dropdown-toggle" + data-toggle="dropdown"> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <span class="sr-only"> + Select merge moment + </span> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge when pipeline succeeds</span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge immediately</span> + </a> + </li> + </ul> + </span> + <template v-if="isMergeAllowed()"> + <label class="spacing"> + <input + v-model="removeSourceBranch" + :disabled="isMergeButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <button + @click="toggleCommitMessageEditor" + :disabled="isMergeButtonDisabled" + class="btn btn-default btn-xs" + type="button"> + Modify commit message + </button> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#">{{commitMessageLinkTitle}}</a> + </div> + </div> + </div> + </div> + </template> + <template v-else> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +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: '', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js new file mode 100644 index 00000000000..f4ab2d9fa58 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -0,0 +1,27 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js new file mode 100644 index 00000000000..cb02ffe93bd --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -0,0 +1,59 @@ +/* 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js new file mode 100644 index 00000000000..b2eb32ead5f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -0,0 +1,42 @@ +/** + * This file is the centerpiece of an attempt to reduce potential conflicts + * between the CE and EE versions of the MR widget. EE additions to the MR widget should + * be contained in the ./vue_merge_request_widget/ee directory, and should **extend** + * rather than mutate CE MR Widget code. + * + * This file should be the only source of conflicts between EE and CE. EE-only components should + * imported directly where they are needed, and import paths for EE extensions of CE components + * should overwrite import paths **without** changing the order of dependencies listed here. + */ + +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 './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 './stores/mr_widget_store'; +export { default as MRWidgetService } from './services/mr_widget_service'; +export { default as eventHub } from './event_hub'; +export { default as getStateKey } from './stores/get_state_key'; +export { default as mrWidgetOptions } from './mr_widget_options'; +export { default as stateMaps } from './stores/state_maps'; +export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js new file mode 100644 index 00000000000..cd65ac069c5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -0,0 +1,12 @@ +import { + Vue, + mrWidgetOptions, +} from './dependencies'; + +document.addEventListener('DOMContentLoaded', () => { + const vm = new Vue(mrWidgetOptions); + + window.gl.mrWidget = { + checkStatus: vm.checkStatus, + }; +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js new file mode 100644 index 00000000000..7c6c2d21714 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -0,0 +1,234 @@ +/* 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, + }; + 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> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js new file mode 100644 index 00000000000..42493be3372 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,57 @@ +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`); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js new file mode 100644 index 00000000000..fee4113f3c8 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -0,0 +1,28 @@ +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; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js new file mode 100644 index 00000000000..faafeae5c5b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,134 @@ +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; + + 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); + } + +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js new file mode 100644 index 00000000000..625d7a01c65 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,36 @@ +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, +}; diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js new file mode 100644 index 00000000000..2a605b24339 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/memory_graph.js @@ -0,0 +1,36 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js new file mode 100644 index 00000000000..ae246ada01b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js @@ -0,0 +1,23 @@ +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> + `, +}; diff --git a/app/assets/javascripts/vue_shared/pipeline_svg_icons.js b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js new file mode 100644 index 00000000000..5af30ae74f0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js @@ -0,0 +1,43 @@ +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, +}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 5bb7e8caec1..d2ec1791d2b 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -47,3 +47,4 @@ @import "framework/emoji-sprites.scss"; @import "framework/icons.scss"; @import "framework/snippets.scss"; +@import "framework/memory_graph.scss"; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1a6f36d032d..57387b913dc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -92,7 +92,8 @@ hr { .item-title { font-weight: 600; } /** FLASH message **/ -.author_link { +.author_link, +.author-link { color: $gl-link-color; } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab8..1b7d4e42258 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,4 +1,5 @@ -.ci-status-icon-success { +.ci-status-icon-success, +.ci-status-icon-passed { color: $green-500; svg { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss new file mode 100644 index 00000000000..8473f2ef094 --- /dev/null +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -0,0 +1,16 @@ +.memory-graph-container { + svg { + background: $white-light; + } + + path { + fill: none; + stroke: $blue-500; + stroke-width: 1px; + } + + circle { + stroke: $blue-700; + fill: $blue-700; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 08bcb582613..4cfa5d718e9 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -111,6 +111,7 @@ $gl-link-hover-color: $blue-800; $gl-grayish-blue: #7f8fa4; $gl-gray: $gl-text-color; $gl-gray-dark: #313236; +$gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; $gl-header-nav-hover-color: #434343; $placeholder-text-color: rgba(0, 0, 0, .42); diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 72660113e3c..f4488ccd8fe 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -37,12 +37,6 @@ @include btn-red; } } - - .dropdown-toggle { - .fa { - color: inherit; - } - } } .accept-control { @@ -88,13 +82,13 @@ } } - .ci_widget { - border-bottom: 1px solid $well-inner-border; + .ci-widget { color: $gl-text-color; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; + padding: $gl-padding-top $gl-padding 0; i, svg { @@ -115,16 +109,15 @@ flex-wrap: wrap; } - .ci-status-icon > .icon-link > svg { + .ci-status-icon > .icon-link svg { width: 22px; height: 22px; } } .mr-widget-body, - .ci_widget, .mr-widget-footer { - padding: 16px; + margin: 16px; } .mr-widget-pipeline-graph { @@ -166,12 +159,41 @@ .normal { color: $gl-text-color; + font-size: 15px; + } + + .capitalize { + text-transform: capitalize; } .js-deployment-link { 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 { h4 { font-weight: 600; @@ -182,6 +204,10 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } + + time { + font-weight: normal; + } } .btn-grouped { @@ -189,6 +215,80 @@ 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) { h4 { font-size: 14px; @@ -220,6 +320,17 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } + + &.mr-state-locked .mr-info-list { + margin-top: 10px; + margin-left: 12px; + } } .mr-widget-footer { @@ -263,6 +374,24 @@ font-size: 90%; margin: 0 3px; 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 { @@ -343,61 +472,74 @@ } } -.remove-message-pipes { - ul { - margin: 10px 0 0 12px; - padding: 0; - list-style: none; - border-left: 2px solid $border-color; - display: inline-block; - } +.mr-info-list { + position: relative; + margin: 10px 0 $gl-padding 12px; - li { + p { + margin: 6px 0; position: relative; - margin: 0; - padding: 0; - display: block; + padding-left: 15px; + + &::before { + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 8px; + width: 8px; + left: 0; + } + + &:last-child { + margin-bottom: 0; - span { - margin-left: 15px; - max-height: 20px; + &::before { + top: 14px; + } } } - li::before { - content: ''; + .legend { + height: 100%; + width: 2px; + background: $border-color; position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; + top: -5px; + } +} + +.mr-info-list.mr-memory-usage { + .legend { + height: 75%; } - li:last-child { + p { + float: left; + padding-left: 20px; + &::before { - top: 18px; + top: 13px; } + } - span { - display: block; - position: relative; - top: 5px; - margin-top: 5px; - } + .memory-graph-container { + float: left; + margin-left: 5px; } } .mr-source-target { background-color: $gray-light; - line-height: 31px; - border-style: solid; - border-width: 1px; - border-color: $border-color; - border-top-right-radius: 3px; - border-top-left-radius: 3px; - border-bottom: none; - padding: 16px; - margin-bottom: -1px; + border-radius: 3px 3px 0 0; + border-bottom: 1px solid $border-color; + padding: 0 $gl-padding; + margin-bottom: 6px; + line-height: 44px; + + .dropdown-toggle .fa { + color: $gl-text-color; + } } .panel-new-merge-request { @@ -587,3 +729,20 @@ } } } + +.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%; + } + } +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 65a1f640a76..8ce9150e4a9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -100,7 +100,10 @@ class ApplicationController < ActionController::Base end 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 def git_not_found! diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 59247280559..d8ed470e461 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -84,6 +84,7 @@ class Projects::BranchesController < Projects::ApplicationController end format.js { render nothing: true, status: result[:return_code] } + format.json { render json: { message: result[:message] }, status: result[:return_code] } end end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 0fd35bcb790..dfaaea71b9c 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -91,7 +91,7 @@ class Projects::BuildsController < Projects::ApplicationController def status render json: BuildSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@build) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 2b5f0383ac1..7c3cce1c241 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -39,7 +39,7 @@ class Projects::CommitController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index c319671456d..b33c0b00ad9 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -10,8 +10,22 @@ class Projects::DeploymentsController < Projects::ApplicationController .represent_concise(deployments) } end + def metrics + @metrics = deployment.metrics(1.hour) + + if @metrics&.any? + render json: @metrics, status: :ok + else + head :no_content + end + end + private + def deployment + @deployment ||= environment.deployments.find_by(iid: params[:id]) + end + def environment @environment ||= project.environments.find(params[:environment_id]) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fa37963dfd4..fd57afbd05f 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -17,7 +17,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .within_folders .represent(@environments), @@ -37,7 +37,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@environments), available_count: folder_environments.available.count, @@ -81,10 +81,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController stop_action = @environment.stop_with_action!(current_user) - if stop_action - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) - else - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + action_or_env_url = + if stop_action + polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) + 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 diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a63b7ff0bed..44c7eb86855 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,11 +10,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check, - :ci_status, :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues + :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] @@ -74,10 +73,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController def show respond_to do |format| - format.html { define_discussion_vars } + format.html do + define_discussion_vars + end format.json do - render json: MergeRequestSerializer.new.represent(@merge_request) + render json: serializer.represent(@merge_request, basic: params[:basic]) end format.patch do @@ -214,7 +215,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -230,7 +231,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -299,17 +300,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def remove_wip - MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) + @merge_request = MergeRequests::UpdateService + .new(project, current_user, wip_event: 'unwip') + .execute(@merge_request) - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + render json: serializer.represent(@merge_request) end def merge_check @merge_request.check_if_can_be_merged - @pipelines = @merge_request.all_pipelines - render partial: "projects/merge_requests/widget/show.html.haml", layout: false + render json: serializer.represent(@merge_request) + end + + def commit_change_content + render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end def cancel_merge_when_pipeline_succeeds @@ -320,65 +325,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) + + render json: serializer.represent(@merge_request) end def merge return access_denied! unless @merge_request.can_be_merged_by?(current_user) - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have - # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) - @status = :failed - return - end - - if params[:sha] != @merge_request.diff_head_sha - @status = :sha_mismatch - return - end - - @merge_request.update(merge_error: nil) + status = merge! - if params[:merge_when_pipeline_succeeds].present? - unless @merge_request.head_pipeline - @status = :failed - return - end - - if @merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - @status = :merge_when_pipeline_succeeds - elsif @merge_request.head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success - else - @status = :failed - end + if @merge_request.merge_error + render json: { status: status, merge_error: @merge_request.merge_error } else - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success + render json: { status: status } end end - def merge_widget_refresh - @status = - if merge_request.merge_when_pipeline_succeeds - :merge_when_pipeline_succeeds - else - # Only MRs that can be merged end in this action - # MR can be already picked up for merge / merged already or can be waiting for worker to be picked up - # in last case it does not have any special status. Possible error is handled inside widget js function - :success - end - - render 'merge' - end - def branch_from # This is always source @source_project = @merge_request.nil? ? @project : @merge_request.source_project @@ -428,37 +390,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def ci_status - pipeline = @merge_request.head_pipeline - @pipelines = @merge_request.all_pipelines - - if pipeline - status = pipeline.status - coverage = pipeline.coverage - - status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? - - status ||= "preparing" - else - ci_service = @merge_request.source_project.try(:ci_service) - status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service - end - - response = { - title: merge_request.title, - sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha), - status: status, - coverage: coverage, - pipeline: pipeline.try(:id), - has_ci: @merge_request.has_ci? - } - - render json: response - end - def pipeline_status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@merge_request.head_pipeline) end @@ -474,10 +408,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController stop_namespace_project_environment_path(project.namespace, project, environment) end + metrics_url = + if can?(current_user, :read_environment, environment) && environment.has_metrics? + metrics_namespace_project_environment_path(environment.project.namespace, + environment.project, + environment, + deployment) + end + { id: environment.id, name: environment.name, url: namespace_project_environment_path(project.namespace, project, environment), + metrics_url: metrics_url, stop_url: stop_url, external_url: environment.external_url, external_url_formatted: environment.formatted_external_url, @@ -555,10 +498,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end - def define_widget_vars - @pipeline = @merge_request.head_pipeline - end - def define_commit_vars @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit @@ -694,4 +633,46 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.close end end + + private + + def merge! + # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # to wait until CI completes to know + unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + return :failed + end + + return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha + + @merge_request.update(merge_error: nil) + + if params[:merge_when_pipeline_succeeds].present? + return :failed unless @merge_request.head_pipeline + + if @merge_request.head_pipeline.active? + MergeRequests::MergeWhenPipelineSucceedsService + .new(@project, current_user, merge_params) + .execute(@merge_request) + + :merge_when_pipeline_succeeds + elsif @merge_request.head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + else + :failed + end + else + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + end + end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 5cb2e428201..7fe3c3c116c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -37,7 +37,7 @@ class Projects::PipelinesController < Projects::ApplicationController render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@pipelines), count: { @@ -74,7 +74,7 @@ class Projects::PipelinesController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipeline, grouped: true) end end @@ -94,7 +94,7 @@ class Projects::PipelinesController < Projects::ApplicationController def status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@pipeline) end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 7656929efe7..fbbce6876c2 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,10 @@ module IssuablesHelper when Issue IssueSerializer.new.represent(issuable).to_json when MergeRequest - MergeRequestSerializer.new.represent(issuable).to_json + MergeRequestSerializer + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 2614cdfe90e..23e55539f0a 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -19,14 +19,6 @@ module MergeRequestsHelper } 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) classes = "merge-request" classes << " closed" if mr.closed? @@ -55,23 +47,6 @@ module MergeRequestsHelper 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) new_namespace_project_merge_request_path( @project.namespace, @project, @@ -85,35 +60,6 @@ module MergeRequestsHelper ) end - def mr_assign_issues_link - issues = MergeRequests::AssignIssuesService.new(@project, - current_user, - merge_request: @merge_request, - closes_issues: mr_closes_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 source_branch_with_namespace(merge_request) - 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 - def format_mr_branch_names(merge_request) source_path = merge_request.source_project_path target_path = merge_request.target_project_path diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 5ac56ac6fa0..6eddeab515e 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -92,7 +92,7 @@ module Mentionable # Uses regex to quickly determine if mentionables might be referenced # Allows heavy processing to be skipped def matches_cross_reference_regex? - reference_pattern = if project.default_issues_tracker? + reference_pattern = if !project || project.default_issues_tracker? ReferenceRegexes::DEFAULT_PATTERN else ReferenceRegexes::EXTERNAL_PATTERN diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 37adfb4de73..f83d9e8edee 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -99,6 +99,21 @@ class Deployment < ActiveRecord::Base created_at.to_time.in_time_zone.to_s(:medium) 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 def ref_path diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 35231bab12e..1b6904aa077 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -864,7 +864,7 @@ class MergeRequest < ActiveRecord::Base end def can_be_cherry_picked? - merge_commit + merge_commit.present? end def has_complete_diff_refs? @@ -908,6 +908,8 @@ class MergeRequest < ActiveRecord::Base end def conflicts_can_be_resolved_by?(user) + return false unless source_project + access = ::Gitlab::UserAccess.new(user, project: source_project) access.can_push_to_branch?(source_branch) end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index fa782c6fbb7..6464bf3f4a4 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -150,7 +150,7 @@ class ChatNotificationService < Service def notify_for_ref?(data) return true if data[:object_attributes][:tag] - return true unless notify_only_default_branch + return true unless notify_only_default_branch? data[:object_attributes][:ref] == project.default_branch end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index ea585721e8f..59776552540 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -10,7 +10,7 @@ class MonitoringService < Service end # Environments have a number of metrics - def metrics(environment) + def metrics(environment, timeframe_start: nil, timeframe_end: nil) raise NotImplementedError end end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6854d2243d7..6a4479c4dbc 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -1,7 +1,6 @@ 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_refresh_interval = 30.seconds self.reactive_cache_lifetime = 1.minute @@ -64,16 +63,22 @@ class PrometheusService < MonitoringService { success: false, result: err } end - def metrics(environment) - with_reactive_cache(environment.slug) do |data| + def metrics(environment, timeframe_start: nil, timeframe_end: nil) + with_reactive_cache(environment.slug, timeframe_start, timeframe_end) do |data| data end end # 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? + 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} 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 success: true, metrics: { # Average Memory used in MB - memory_values: client.query_range(memory_query, start: 8.hours.ago), - memory_current: client.query(memory_query), + memory_values: client.query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_current: client.query(memory_query, time: timeframe_end), + memory_previous: client.query(memory_query, time: timeframe_start), # Average CPU Utilization - cpu_values: client.query_range(cpu_query, start: 8.hours.ago), - cpu_current: client.query(cpu_query) + cpu_values: client.query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_current: client.query(cpu_query, time: timeframe_end), + cpu_previous: client.query(cpu_query, time: timeframe_start) }, last_update: Time.now.utc } diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb new file mode 100644 index 00000000000..255f63db5c2 --- /dev/null +++ b/app/presenters/merge_request_presenter.rb @@ -0,0 +1,168 @@ +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 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 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 + + 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_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 diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 311ee9c96be..4e6c15f673b 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -3,8 +3,10 @@ class BaseSerializer @request = EntityRequest.new(parameters) end - def represent(resource, opts = {}) - self.class.entity_class + def represent(resource, opts = {}, entity_class = nil) + entity_class = entity_class || self.class.entity_class + + entity_class .represent(resource, opts.merge(request: @request)) .as_json end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 75dda1af709..5e99204c658 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 1380b347d8e..e2276808b90 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -26,11 +26,11 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :update_build, build) + build.playable? && can?(request.current_user, :update_build, build) end def detailed_status - build.detailed_status(request.user) + build.detailed_status(request.current_user) end def path_to(route, build) diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4ff15a78115..4e8a3c67b21 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -31,7 +31,7 @@ class EnvironmentEntity < Grape::Entity end expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment| - can?(request.user, :admin_environment, environment.project) && + can?(request.current_user, :admin_environment, environment.project) && terminal_namespace_project_environment_path( environment.project.namespace, environment.project, diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 00000000000..935d67a4f37 --- /dev/null +++ b/app/serializers/event_entity.rb @@ -0,0 +1,4 @@ +class EventEntity < Grape::Entity + expose :author, using: UserEntity + expose :updated_at +end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb index a4d3737429c..04487e59009 100644 --- a/app/serializers/job_group_entity.rb +++ b/app/serializers/job_group_entity.rb @@ -11,6 +11,6 @@ class JobGroupEntity < Grape::Entity alias_method :group, :object def detailed_status - group.detailed_status(request.user) + group.detailed_status(request.current_user) end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 00000000000..8771345c135 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,10 @@ +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 +end diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 00000000000..cc5c664c8fa --- /dev/null +++ b/app/serializers/merge_request_basic_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestBasicSerializer < BaseSerializer + entity MergeRequestBasicEntity +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 453ba52b892..a2542c54f7a 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,6 @@ class MergeRequestEntity < IssuableEntity + include RequestAwareEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at @@ -12,4 +14,174 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # 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 diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa6e00dfcb4..f67034ce47a 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -1,3 +1,9 @@ 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 diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 7eb7aac72eb..51ad0a3f8ba 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity expose :id expose :user, using: UserEntity + expose :active?, as: :active + expose :coverage expose :path do |pipeline| namespace_project_pipeline_path( @@ -69,16 +71,16 @@ class PipelineEntity < Grape::Entity alias_method :pipeline, :object def can_retry? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.retryable? end def can_cancel? - can?(request.user, :update_pipeline, pipeline) && + can?(request.current_user, :update_pipeline, pipeline) && pipeline.cancelable? end def detailed_status - pipeline.detailed_status(request.user) + pipeline.detailed_status(request.current_user) end end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index e7a9df8ac4e..e37af63774c 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -37,4 +37,11 @@ class PipelineSerializer < BaseSerializer data = represent(resource, { only: [{ details: [:status] }] }) data.dig(:details, :status) || {} end + + def represent_stages(resource) + return {} unless resource.present? + + data = represent(resource, { only: [{ details: [:stages] }] }) + data.dig(:details, :stages) || [] + end end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 97ced8730ed..cee0089056f 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -35,6 +35,6 @@ class StageEntity < Grape::Entity alias_method :stage, :object def detailed_status - stage.detailed_status(request.user) + stage.detailed_status(request.current_user) end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 33edcd60944..25ba54ffa0d 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -50,7 +50,7 @@ module Ci when 'always' %w[success failed skipped] when 'manual' - %w[success] + %w[success skipped] else [] end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index cdcac7e4264..e4dfe0c8c08 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -35,7 +35,7 @@ = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests - %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) + %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 9e306d4543c..25b8567b78f 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" -- page_description @merge_request.description +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') @@ -11,42 +11,17 @@ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/show/mr_box" - .append-bottom-default.mr-source-target.prepend-top-default - - if @merge_request.open? - .pull-right - - if @merge_request.source_branch_exists? - - if koding_enabled? && @repository.koding_yml - = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do - Run in IDE (Koding) - = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do - Check out branch - - %span.dropdown.inline.prepend-left-5 - %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } - Download as - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) - %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) - .normal - %span <b>Request to merge</b> - %span.label-branch= source_branch_with_namespace(@merge_request) - %span <b>into</b> - %span.label-branch - = link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) - - if @merge_request.open? && @merge_request.diverged_from_target_branch? - %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) - if @merge_request.source_branch_exists? = render "projects/merge_requests/show/how_to_merge" - = render "projects/merge_requests/widget/show.html.haml" + :javascript + window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + + #js-vue-mr-widget.mr-widget - - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .merge-manually.light.prepend-top-default - You can also accept this merge request manually using the - = succeed '.' do - = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" + - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('vue_merge_request_widget') .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true @@ -113,9 +88,7 @@ :javascript $(function () { - new MergeRequest({ + window.mergeRequest = new MergeRequest({ action: "#{controller.action_name}" }); }); - - var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}"; diff --git a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml deleted file mode 100644 index eab5be488b5..00000000000 --- a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}"); diff --git a/app/views/projects/merge_requests/merge.js.haml b/app/views/projects/merge_requests/merge.js.haml deleted file mode 100644 index e632fc681cf..00000000000 --- a/app/views/projects/merge_requests/merge.js.haml +++ /dev/null @@ -1,14 +0,0 @@ -- case @status -- when :success - - remove_source_branch = params[:should_remove_source_branch] == '1' || @merge_request.remove_source_branch? - :plain - merge_request_widget.mergeInProgress(#{remove_source_branch}); -- when :merge_when_pipeline_succeeds - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); -- when :sha_mismatch - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); -- else - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}"); diff --git a/app/views/projects/merge_requests/widget/_closed.html.haml b/app/views/projects/merge_requests/widget/_closed.html.haml deleted file mode 100644 index 15f47ecf210..00000000000 --- a/app/views/projects/merge_requests/widget/_closed.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Closed - - if @merge_request.closed_event - by #{link_to_member(@project, @merge_request.closed_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.closed_event.created_at)} - %p - = succeed '.' do - The changes were not merged into - %span.label-branch= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml new file mode 100644 index 00000000000..ad0ce7bf501 --- /dev/null +++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml @@ -0,0 +1,4 @@ +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml deleted file mode 100644 index 1298376ac25..00000000000 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- if @pipeline - .mr-widget-heading - - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - %div{ class: "ci-status-icon ci-status-icon-#{status}" } - = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do - = ci_icon_for_status(status) - %span - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' - = ci_label_for_status(status) - - if @pipeline.stages.any? - .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph' - %span - for - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" - %span.ci-coverage - -- elsif @merge_request.has_ci? - -# Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - -# TODO, remove in later versions when services like Jenkins will set CI status via Commit status API - .mr-widget-heading - - %w[success skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: "display:none" } - = ci_icon_for_status(status) - %span - CI job - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace" - %span.ci-coverage - - .ci_widget - = icon("spinner spin") - Checking CI status for #{@merge_request.diff_head_commit.short_id}… - - .ci_widget.ci-not_found{ style: "display:none" } - = icon("times-circle") - Could not find CI status for #{@merge_request.diff_head_commit.short_id}. - - .ci_widget.ci-error{ style: "display:none" } - = icon("times-circle") - Could not connect to the CI server. Please check your settings and try again. - -.js-success-icon.hidden - = ci_icon_for_status('success') diff --git a/app/views/projects/merge_requests/widget/_locked.html.haml b/app/views/projects/merge_requests/widget/_locked.html.haml deleted file mode 100644 index 78d0783cba0..00000000000 --- a/app/views/projects/merge_requests/widget/_locked.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - = icon("spinner spin") - Merge in progress… - %p - This merge request is in the process of being merged, during which time it is locked and cannot be closed. - diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml deleted file mode 100644 index adc3bbc37f3..00000000000 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Merged - - if @merge_request.merge_event - by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget.remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.remove-message-pipes.hide - %ul - %li - %span - Failed to remove source branch '#{@merge_request.source_branch}'. - .remove_source_branch_in_progress.remove-message-pipes.hide - %ul - %li - %span - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. - %li - %span - Please wait, this page will be automatically reloaded. - - else - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml deleted file mode 100644 index a0f54bd28ec..00000000000 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ /dev/null @@ -1,14 +0,0 @@ -- can_remove_source_branch = local_assigns.fetch(:source_branch_exists, false) && @merge_request.can_remove_source_branch?(current_user) -- mr_can_be_reverted = @merge_request.can_be_reverted?(current_user) -- mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - -- if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .clearfix.merged-buttons - - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do - = icon('trash-o') - Remove source branch - - if mr_can_be_reverted - = revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close") - - if mr_can_be_cherry_picked - = cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default") diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml deleted file mode 100644 index 0872a1a0503..00000000000 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ /dev/null @@ -1,49 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - -# After conflicts are resolved, the user is redirected back to the MR page. - -# There is a short window before background workers run and GitLab processes - -# the new push and commits, during which it will think the conflicts still exist. - -# We send this param to get the widget to treat the MR as having no more conflicts. - - resolved_conflicts = params[:resolved_conflicts] - - - if @project.archived? - = render 'projects/merge_requests/widget/open/archived' - - elsif @merge_request.branch_missing? - = render 'projects/merge_requests/widget/open/missing_branch' - - elsif @merge_request.has_no_commits? - = render 'projects/merge_requests/widget/open/nothing' - - elsif @merge_request.unchecked? - = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? && !resolved_conflicts - = render 'projects/merge_requests/widget/open/conflicts' - - elsif @merge_request.work_in_progress? - = render 'projects/merge_requests/widget/open/wip' - - elsif @merge_request.merge_when_pipeline_succeeds? && @merge_request.merge_error.present? - = render 'projects/merge_requests/widget/open/error' - - elsif @merge_request.merge_when_pipeline_succeeds? - = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds' - - elsif !@merge_request.can_be_merged_by?(current_user) - = render 'projects/merge_requests/widget/open/not_allowed' - - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?) - = render 'projects/merge_requests/widget/open/build_failed' - - elsif !@merge_request.mergeable_discussions_state? - = render 'projects/merge_requests/widget/open/unresolved_discussions' - - elsif @pipeline&.blocked? - = render 'projects/merge_requests/widget/open/manual' - - elsif @merge_request.can_be_merged? || resolved_conflicts - = render 'projects/merge_requests/widget/open/accept' - - - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present? - .mr-widget-footer - %span - = icon('check') - - if mr_closes_issues.present? - Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} - = succeed '.' do - != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link - - if mr_issues_mentioned_but_not_closing.present? - #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed. diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml deleted file mode 100644 index c716b69b35b..00000000000 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ /dev/null @@ -1,40 +0,0 @@ -- if @merge_request.open? - = render 'projects/merge_requests/widget/open' -- elsif @merge_request.merged? - = render 'projects/merge_requests/widget/merged' -- elsif @merge_request.closed? - = render 'projects/merge_requests/widget/closed' -- elsif @merge_request.locked? - = render 'projects/merge_requests/widget/locked' - -:javascript - var opts = { - merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - check_enable: #{@merge_request.unchecked? ? "true" : "false"}, - ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - pipeline_status_url: "#{pipeline_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - gitlab_icon: "#{asset_path 'gitlab_logo.png'}", - ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}", - ci_message: { - normal: "Pipeline {{status}} for \"{{title}}\"", - preparing: "{{status}} pipeline for \"{{title}}\"" - }, - ci_enable: #{@project.ci_service ? "true" : "false"}, - ci_title: { - preparing: "{{status}} pipeline", - normal: "Pipeline {{status}}" - }, - ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}", - ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json}, - commits_path: "#{project_commits_path(@project)}", - pipeline_path: "#{project_pipelines_path(@project)}", - pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" - }; - - if (typeof merge_request_widget !== 'undefined') { - merge_request_widget.cancelPolling(); - merge_request_widget.clearEventListeners(); - } - - merge_request_widget = new window.gl.MergeRequestWidget(opts); diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml deleted file mode 100644 index 4cbd22150c7..00000000000 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -= form_for [:merge, @project.namespace.becomes(Namespace), @project, @merge_request], remote: true, method: :post, html: { class: 'accept-mr-form js-quick-submit js-requires-input' } do |f| - = hidden_field_tag :authenticity_token, form_authenticity_token - = hidden_field_tag :sha, @merge_request.diff_head_sha - .accept-merge-holder.clearfix.js-toggle-container - .clearfix - .accept-action - - if @pipeline && @pipeline.active? - %span.btn-group - = button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do - Merge when pipeline succeeds - - unless @project.only_allow_merge_if_pipeline_succeeds? - = button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do - = icon('caret-down') - %span.sr-only - Select merge moment - %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li - = link_to "#", class: "merge-when-pipeline-succeeds" do - = icon('check fw') - Merge when pipeline succeeds - %li - = link_to "#", class: "accept-merge-request" do - = icon('warning fw') - Merge immediately - - else - = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do - Accept merge request - - if @merge_request.force_remove_source_branch? - .accept-control - The source branch will be removed. - - elsif @merge_request.can_remove_source_branch?(current_user) - .accept-control.checkbox - = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do - = check_box_tag :should_remove_source_branch - Remove source branch - .accept-control - %button.modify-merge-commit-link.js-toggle-button{ type: "button" } - = icon('edit') - Modify commit message - .js-toggle-content.hide.prepend-top-default - = render 'shared/commit_message_container', params: params, - message_with_description: @merge_request.merge_commit_message(include_description: true), - message_without_description: @merge_request.merge_commit_message, - text: @merge_request.merge_commit_message, - rows: 14, hint: true - - = hidden_field_tag :merge_when_pipeline_succeeds, "", autocomplete: "off" diff --git a/app/views/projects/merge_requests/widget/open/_archived.html.haml b/app/views/projects/merge_requests/widget/open/_archived.html.haml deleted file mode 100644 index 0d61e56d8fb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_archived.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Project is archived -%p - This merge request cannot be merged because archived projects cannot be written to. diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml deleted file mode 100644 index 3979d5fa8ed..00000000000 --- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon('exclamation-triangle') - The pipeline for this merge request failed - -%p - Please retry the job or push a new commit to fix the failure. diff --git a/app/views/projects/merge_requests/widget/open/_check.html.haml b/app/views/projects/merge_requests/widget/open/_check.html.haml deleted file mode 100644 index 909dc52fc06..00000000000 --- a/app/views/projects/merge_requests/widget/open/_check.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%strong - = icon("spinner spin") - Checking ability to merge automatically… diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml deleted file mode 100644 index 621ee313026..00000000000 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- can_resolve = @merge_request.conflicts_can_be_resolved_by?(current_user) -- can_resolve_in_ui = @merge_request.conflicts_can_be_resolved_in_ui? -- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user) - -%h4.has-conflicts - %p - = icon("exclamation-triangle") - This merge request contains merge conflicts - -.remove-message-pipes - %ul - %li - %span - To merge this request, resolve these conflicts - - if can_resolve && !can_resolve_in_ui - locally - or - - unless can_merge - ask someone with write access to this repository to - merge it locally. - -- if (can_resolve && can_resolve_in_ui) || can_merge - .merged-buttons.clearfix - - if can_resolve && can_resolve_in_ui - = link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn" - - if can_merge - = link_to "Merge locally", "#modal_merge_info", class: "btn how_to_merge_link vlink", "data-toggle" => "modal" diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml deleted file mode 100644 index 9078b7e21dd..00000000000 --- a/app/views/projects/merge_requests/widget/open/_manual.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Pipeline blocked -%p - The pipeline for this merge request requires a manual action to proceed. diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml deleted file mode 100644 index 76cc1ecd8a5..00000000000 --- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml +++ /dev/null @@ -1,33 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%h4 - Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)} - to be merged automatically when the pipeline succeeds. -.remove-message-pipes - %ul - %li - %span - = succeed '.' do - The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"} - - if @merge_request.remove_source_branch? - %li - %span - The source branch will be removed. - - else - %li - %span - The source branch will not be removed. - - - remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user - - user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - - if remove_source_branch_button || user_can_cancel_automatic_merge - .clearfix.prepend-top-10 - - if remove_source_branch_button - = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do - = icon('times') - Remove source branch when merged - - - if user_can_cancel_automatic_merge - = link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do - Cancel automatic merge diff --git a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml b/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml deleted file mode 100644 index c9f07629493..00000000000 --- a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- unless @merge_request.source_branch_exists? - %h4 - = icon("exclamation-triangle") - Source branch - %span.label-branch= source_branch_with_namespace(@merge_request) - does not exist - %p - Please restore the source branch or close this merge request and open a new merge request with a different source branch. -- else - %h4 - = icon("exclamation-triangle") - Target branch - %span.label-branch= @merge_request.target_branch - does not exist - %p - Please restore the target branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml b/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml deleted file mode 100644 index 57ce1959021..00000000000 --- a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - Ready to be merged automatically -%p - Ask someone with write access to this repository to merge this request. - - if @merge_request.force_remove_source_branch? - The source branch will be removed. diff --git a/app/views/projects/merge_requests/widget/open/_nothing.html.haml b/app/views/projects/merge_requests/widget/open/_nothing.html.haml deleted file mode 100644 index 7af8c01c134..00000000000 --- a/app/views/projects/merge_requests/widget/open/_nothing.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%h4 - = icon("exclamation-triangle") - Nothing to merge from - %span.label-branch= source_branch_with_namespace(@merge_request) - into - %span.label-branch= @merge_request.target_branch -%p - Please push new commits to the source branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_reload.html.haml b/app/views/projects/merge_requests/widget/open/_reload.html.haml deleted file mode 100644 index acfc31725eb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_reload.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request failed to be merged automatically - -%p - Please reload the page to find out the reason. diff --git a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml b/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml deleted file mode 100644 index 499624f8dd8..00000000000 --- a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request has received new commits since the page was loaded. - -%p - Please reload the page to review the new commits before merging. diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml deleted file mode 100644 index ec9346ce89b..00000000000 --- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%h4 - = icon('exclamation-triangle') - This merge request has unresolved discussions - -%p - Please resolve these discussions - - if @project.issues_enabled? && can?(current_user, :create_issue, @project) - or - = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid) - to allow this merge request to be merged. diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml deleted file mode 100644 index c296422a9cf..00000000000 --- a/app/views/projects/merge_requests/widget/open/_wip.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -%h4 - This merge request is currently a Work In Progress - -- if can?(current_user, :update_merge_request, @merge_request) - %p - When this merge request is ready, - = link_to remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post do - remove the - %code WIP: - prefix from the title - to allow it to be merged. diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 03309722326..d23f79be2be 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,12 +5,3 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) - -.form-group - .col-sm-10.col-sm-offset-2 - - if issuable.can_remove_source_branch?(current_user) - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. |