diff options
113 files changed, 1544 insertions, 652 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5b101ad6b..c039335c46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,12 @@ entry. - Remove deprecated GitlabCiService. - Requeue pending deletion projects. +## 8.16.7 (2017-02-27) + +- No changes. +- No changes. +- Fix MR changes tab size count when there are over 100 files in the diff. + ## 8.16.6 (2017-02-17) - API: Fix file downloading. !0 (8267) @@ -20,7 +20,7 @@ gem 'rugged', '~> 0.24.0' # Authentication libraries gem 'devise', '~> 4.2' gem 'doorkeeper', '~> 4.2.0' -gem 'omniauth', '~> 1.3.2' +gem 'omniauth', '~> 1.4.2' gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-cas3', '~> 1.1.2' diff --git a/Gemfile.lock b/Gemfile.lock index b46b09c7544..5ff18442c4f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -328,7 +328,7 @@ GEM temple (~> 0.7.6) thor tilt - hashie (3.4.4) + hashie (3.5.5) health_check (2.2.1) rails (>= 4.0) hipchat (1.5.2) @@ -441,7 +441,7 @@ GEM octokit (4.6.2) sawyer (~> 0.8.0, >= 0.5.3) oj (2.17.4) - omniauth (1.3.2) + omniauth (1.4.2) hashie (>= 1.2, < 4) rack (>= 1.0, < 3) omniauth-auth0 (1.4.1) @@ -920,7 +920,7 @@ DEPENDENCIES oauth2 (~> 1.2.0) octokit (~> 4.6.2) oj (~> 2.17.4) - omniauth (~> 1.3.2) + omniauth (~> 1.4.2) omniauth-auth0 (~> 1.4.1) omniauth-authentiq (~> 0.3.0) omniauth-azure-oauth2 (~> 0.0.6) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 53d8d313e39..c51860d1604 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -7,8 +7,6 @@ /* global Aside */ window.$ = window.jQuery = require('jquery'); -require('jquery-ui/ui/draggable'); -require('jquery-ui/ui/sortable'); require('jquery-ujs'); require('vendor/jquery.endless-scroll'); require('vendor/jquery.highlight'); diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index c0dc7a79b0c..6e6e9b18686 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -7,7 +7,7 @@ var DOWN_BUILD_TRACE = '#down-build-trace'; this.Build = (function() { - Build.interval = null; + Build.timeout = null; Build.state = null; @@ -31,7 +31,7 @@ this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); - clearInterval(Build.interval); + clearTimeout(Build.timeout); // Init breakpoint checker this.bp = Breakpoints.get(); @@ -52,17 +52,7 @@ this.getInitialBuildTrace(); this.initScrollButtonAffix(); } - if (this.buildStatus === "running" || this.buildStatus === "pending") { - Build.interval = setInterval((function(_this) { - // Check for new build output if user still watching build page - // Only valid for runnig build when output changes during time - return function() { - if (_this.location() === _this.pageUrl) { - return _this.getBuildTrace(); - } - }; - })(this), 4000); - } + this.invokeBuildTrace(); } Build.prototype.initSidebar = function() { @@ -75,6 +65,22 @@ return window.location.href.split("#")[0]; }; + Build.prototype.invokeBuildTrace = function() { + var continueRefreshStatuses = ['running', 'pending']; + // Continue to update build trace when build is running or pending + if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) { + // Check for new build output if user still watching build page + // Only valid for runnig build when output changes during time + Build.timeout = setTimeout((function(_this) { + return function() { + if (_this.location() === _this.pageUrl) { + return _this.getBuildTrace(); + } + }; + })(this), 4000); + } + }; + Build.prototype.getInitialBuildTrace = function() { var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; @@ -105,6 +111,7 @@ if (log.state) { _this.state = log.state; } + _this.invokeBuildTrace(); if (log.status === "running") { if (log.append) { $('.js-build-output').append(log.html); diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 49bb64a3472..17d14dc1e79 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -52,6 +52,30 @@ return this.views[viewMode].call(this); }; + ImageFile.prototype.initDraggable = function($el, padding, callback) { + var dragging = false; + var $body = $('body'); + var $offsetEl = $el.parent(); + + $el.off('mousedown').on('mousedown', function() { + dragging = true; + $body.css('user-select', 'none'); + }); + + $body.off('mouseup').off('mousemove').on('mouseup', function() { + dragging = false; + $body.css('user-select', ''); + }) + .on('mousemove', function(e) { + var left; + if (!dragging) return; + + left = e.pageX - ($offsetEl.offset().left + padding); + + callback(e, left); + }); + }; + prepareFrames = function(view) { var maxHeight, maxWidth; maxWidth = 0; @@ -96,26 +120,30 @@ maxHeight = 0; return $('.swipe.view', this.file).each((function(_this) { return function(index, view) { - var ref; + var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref; ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; - $('.swipe-frame', view).css({ + $swipeFrame = $('.swipe-frame', view); + $swipeWrap = $('.swipe-wrap', view); + $swipeBar = $('.swipe-bar', view); + + $swipeFrame.css({ width: maxWidth + 16, height: maxHeight + 28 }); - $('.swipe-wrap', view).css({ + $swipeWrap.css({ width: maxWidth + 1, height: maxHeight + 2 }); - return $('.swipe-bar', view).css({ + $swipeBar.css({ left: 0 - }).draggable({ - axis: 'x', - containment: 'parent', - drag: function(event) { - return $('.swipe-wrap', view).width((maxWidth + 1) - $(this).position().left); - }, - stop: function(event) { - return $('.swipe-wrap', view).width((maxWidth + 1) - $(this).position().left); + }); + + wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); + + _this.initDraggable($swipeBar, wrapPadding, function(e, left) { + if (left > 0 && left < $swipeFrame.width() - (wrapPadding * 2)) { + $swipeWrap.width((maxWidth + 1) - left); + $swipeBar.css('left', left); } }); }; @@ -128,9 +156,14 @@ dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); return $('.onion-skin.view', this.file).each((function(_this) { return function(index, view) { - var ref; + var $frame, $track, $dragger, $frameAdded, framePadding, ref, dragging = false; ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; - $('.onion-skin-frame', view).css({ + $frame = $('.onion-skin-frame', view); + $frameAdded = $('.frame.added', view); + $track = $('.drag-track', view); + $dragger = $('.dragger', $track); + + $frame.css({ width: maxWidth + 16, height: maxHeight + 28 }); @@ -138,16 +171,18 @@ width: maxWidth + 1, height: maxHeight + 2 }); - return $('.dragger', view).css({ + $dragger.css({ left: dragTrackWidth - }).draggable({ - axis: 'x', - containment: 'parent', - drag: function(event) { - return $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth); - }, - stop: function(event) { - return $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth); + }); + + framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); + + _this.initDraggable($dragger, framePadding, function(e, left) { + var opacity = left / dragTrackWidth; + + if (opacity >= 0 && opacity <= 1) { + $dragger.css('left', left); + $frameAdded.css('opacity', opacity); } }); }; diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 index ad9d1d21a79..5d780bddb0e 100644 --- a/app/assets/javascripts/environments/components/environment_item.js.es6 +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -503,32 +503,30 @@ module.exports = Vue.component('environment-item', { </span> </td> - <td class="hidden-xs"> - <div v-if="!model.isFolder"> - <div class="btn-group" role="group"> - <actions-component v-if="hasManualActions && canCreateDeployment" - :play-icon-svg="playIconSvg" - :actions="manualActions"> - </actions-component> - - <external-url-component v-if="externalURL && canReadEnvironment" - :external-url="externalURL"> - </external-url-component> - - <stop-component v-if="hasStopAction && canCreateDeployment" - :stop-url="model.stop_path"> - </stop-component> - - <terminal-button-component v-if="model && model.terminal_path" - :terminal-icon-svg="terminalIconSvg" - :terminal-path="model.terminal_path"> - </terminal-button-component> - - <rollback-component v-if="canRetry && canCreateDeployment" - :is-last-deployment="isLastDeployment" - :retry-url="retryUrl"> - </rollback-component> - </div> + <td class="hidden-xs environments-actions"> + <div v-if="!model.isFolder" class="btn-group pull-right" role="group"> + <actions-component v-if="hasManualActions && canCreateDeployment" + :play-icon-svg="playIconSvg" + :actions="manualActions"> + </actions-component> + + <external-url-component v-if="externalURL && canReadEnvironment" + :external-url="externalURL"> + </external-url-component> + + <stop-component v-if="hasStopAction && canCreateDeployment" + :stop-url="model.stop_path"> + </stop-component> + + <terminal-button-component v-if="model && model.terminal_path" + :terminal-icon-svg="terminalIconSvg" + :terminal-path="model.terminal_path"> + </terminal-button-component> + + <rollback-component v-if="canRetry && canCreateDeployment" + :is-last-deployment="isLastDeployment" + :retry-url="retryUrl"> + </rollback-component> </div> </td> </tr> diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 698870d0ce1..6d86888dcb8 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -1,16 +1,16 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len, one-var, one-var-declaration-per-line, quotes, prefer-template, newline-per-chained-call, comma-dangle, new-cap, no-else-return, consistent-return */ /* global FilesCommentButton */ +/* global notes */ (function() { + let $commentButtonTemplate; var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; this.FilesCommentButton = (function() { - var COMMENT_BUTTON_CLASS, COMMENT_BUTTON_TEMPLATE, DEBOUNCE_TIMEOUT_DURATION, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; + var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; COMMENT_BUTTON_CLASS = '.add-diff-note'; - COMMENT_BUTTON_TEMPLATE = _.template('<button name="button" type="submit" class="btn <%- COMMENT_BUTTON_CLASS %> js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>'); - LINE_HOLDER_CLASS = '.line_holder'; LINE_NUMBER_CLASS = 'diff-line-num'; @@ -27,26 +27,29 @@ TEXT_FILE_SELECTOR = '.text-file'; - DEBOUNCE_TIMEOUT_DURATION = 100; - function FilesCommentButton(filesContainerElement) { - var debounce; - this.filesContainerElement = filesContainerElement; - this.destroy = bind(this.destroy, this); this.render = bind(this.render, this); - this.VIEW_TYPE = $('input#view[type=hidden]').val(); - debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION); - $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); + this.hideButton = bind(this.hideButton, this); + this.isParallelView = notes.isParallelView(); + filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) + .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); } FilesCommentButton.prototype.render = function(e) { - var $currentTarget, buttonParentElement, lineContentElement, textFileElement; + var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button; $currentTarget = $(e.currentTarget); - - buttonParentElement = this.getButtonParent($currentTarget); - if (!this.validateButtonParent(buttonParentElement)) return; lineContentElement = this.getLineContent($currentTarget); - if (!this.validateLineContent(lineContentElement)) return; + buttonParentElement = this.getButtonParent($currentTarget); + + if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return; + + $button = $(COMMENT_BUTTON_CLASS, buttonParentElement); + buttonParentElement.addClass('is-over') + .nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over'); + + if ($button.length) { + return; + } textFileElement = this.getTextFileElement($currentTarget); buttonParentElement.append(this.buildButton({ @@ -61,19 +64,16 @@ })); }; - FilesCommentButton.prototype.destroy = function(e) { - if (this.isMovingToSameType(e)) { - return; - } - $(COMMENT_BUTTON_CLASS, this.getButtonParent($(e.currentTarget))).remove(); + FilesCommentButton.prototype.hideButton = function(e) { + var $currentTarget = $(e.currentTarget); + var buttonParentElement = this.getButtonParent($currentTarget); + + buttonParentElement.removeClass('is-over') + .nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over'); }; FilesCommentButton.prototype.buildButton = function(buttonAttributes) { - var initializedButtonTemplate; - initializedButtonTemplate = COMMENT_BUTTON_TEMPLATE({ - COMMENT_BUTTON_CLASS: COMMENT_BUTTON_CLASS.substr(1) - }); - return $(initializedButtonTemplate).attr({ + return $commentButtonTemplate.clone().attr({ 'data-noteable-type': buttonAttributes.noteableType, 'data-noteable-id': buttonAttributes.noteableID, 'data-commit-id': buttonAttributes.commitID, @@ -86,14 +86,14 @@ }; FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) { - return $(hoveredElement.closest(TEXT_FILE_SELECTOR)); + return hoveredElement.closest(TEXT_FILE_SELECTOR); }; FilesCommentButton.prototype.getLineContent = function(hoveredElement) { if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) { return hoveredElement; } - if (this.VIEW_TYPE === 'inline') { + if (!this.isParallelView) { return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS); } else { return $(hoveredElement).next("." + LINE_CONTENT_CLASS); @@ -101,7 +101,7 @@ }; FilesCommentButton.prototype.getButtonParent = function(hoveredElement) { - if (this.VIEW_TYPE === 'inline') { + if (!this.isParallelView) { if (hoveredElement.hasClass(OLD_LINE_CLASS)) { return hoveredElement; } @@ -114,17 +114,8 @@ } }; - FilesCommentButton.prototype.isMovingToSameType = function(e) { - var newButtonParent; - newButtonParent = this.getButtonParent($(e.toElement)); - if (!newButtonParent) { - return false; - } - return newButtonParent.is(this.getButtonParent($(e.currentTarget))); - }; - FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) { - return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS) && $(COMMENT_BUTTON_CLASS, buttonParentElement).length === 0; + return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS); }; FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { @@ -135,6 +126,8 @@ })(); $.fn.filesCommentButton = function() { + $commentButtonTemplate = $('<button name="button" type="submit" class="add-diff-note js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>'); + if (!(this && (this.parent().data('can-create-note') != null))) { return; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 index fbc72a3001a..dd565da507e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 @@ -48,7 +48,11 @@ } setOffset(offset = 0) { - this.dropdown.style.left = `${offset}px`; + if (window.innerWidth > 480) { + this.dropdown.style.left = `${offset}px`; + } else { + this.dropdown.style.left = '0px'; + } } renderContent(forceShowList = false) { diff --git a/app/assets/javascripts/lib/utils/common_utils.js.es6 b/app/assets/javascripts/lib/utils/common_utils.js.es6 index 45a1d90a9d9..0242350f718 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js.es6 +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -296,5 +296,58 @@ * @returns {Boolean} */ w.gl.utils.convertPermissionToBoolean = permission => permission === 'true'; + + /** + * Back Off exponential algorithm + * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error> + * + * @param {Function<next, stop>} fn function to be called + * @param {Number} timeout + * @return {Promise<Any, Error>} + * @example + * ``` + * backOff(function (next, stop) { + * // Let's perform this function repeatedly for 60s or for the timeout provided. + * + * ourFunction() + * .then(function (result) { + * // continue if result is not what we need + * next(); + * + * // when result is what we need let's stop with the repetions and jump out of the cycle + * stop(result); + * }) + * .catch(function (error) { + * // if there is an error, we need to stop this with an error. + * stop(error); + * }) + * }, 60000) + * .then(function (result) {}) + * .catch(function (error) { + * // deal with errors passed to stop() + * }) + * ``` + */ + w.gl.utils.backOff = (fn, timeout = 60000) => { + const maxInterval = 32000; + let nextInterval = 2000; + + 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), nextInterval); + nextInterval = Math.min(nextInterval + nextInterval, maxInterval); + } else { + reject(new Error('BACKOFF_TIMEOUT')); + } + }; + + fn(next, stop); + }); + }; })(window); }).call(window); diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js new file mode 100644 index 00000000000..bc109a69c20 --- /dev/null +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -0,0 +1,10 @@ +/** + * exports HTTP status codes + */ + +const statusCodes = { + NO_CONTENT: 204, + OK: 200, +}; + +module.exports = statusCodes; diff --git a/app/assets/javascripts/profile/profile.js.es6 b/app/assets/javascripts/profile/profile.js.es6 index 81374296522..4ccea0624ee 100644 --- a/app/assets/javascripts/profile/profile.js.es6 +++ b/app/assets/javascripts/profile/profile.js.es6 @@ -84,13 +84,14 @@ } $(function() { - $(document).on('focusout.ssh_key', '#key_key', function() { + $(document).on('input.ssh_key', '#key_key', function() { const $title = $('#key_title'); const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); - if (comment && comment.length > 1 && $title.val() === '') { + + // Extract the SSH Key title from its comment + if (comment && comment.length > 1) { return $title.val(comment[1]).change(); } - // Extract the SSH Key title from its comment }); if (global.utils.getPagePath() === 'profiles') { return new Profile(); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index dfe24d1fb33..b1402c0a880 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,3 +1,4 @@ +/* global Flash */ require('vendor/task_list'); class TaskList { @@ -6,6 +7,16 @@ class TaskList { this.dataType = options.dataType; this.fieldName = options.fieldName; this.onSuccess = options.onSuccess || (() => {}); + this.onError = function showFlash(response) { + let errorMessages = ''; + + if (response.responseJSON) { + errorMessages = response.responseJSON.errors.join(' '); + } + + return new Flash(errorMessages || 'Update failed', 'alert'); + }; + this.init(); } @@ -32,6 +43,7 @@ class TaskList { url: $target.data('update-url') || $('form.js-issuable-update').attr('action'), data: patchData, success: this.onSuccess, + error: this.onError, }); } } diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 index c953a589456..e20085d1fd2 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 @@ -33,18 +33,16 @@ }, template: ` <td class="pipeline-actions hidden-xs"> - <div class="controls pull-right"> - <div class="btn-group inline"> - <div class="btn-group"> + <div class="pull-right"> + <div class="btn-group"> + <div class="btn-group" v-if="actions"> <button - v-if='actions' class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions" data-toggle="dropdown" title="Manual job" data-placement="top" - aria-label="Manual job" - > - <span v-html='svgs.iconPlay' aria-hidden="true"></span> + aria-label="Manual job"> + <span v-html="svgs.iconPlay" aria-hidden="true"></span> <i class="fa fa-caret-down" aria-hidden="true"></i> </button> <ul class="dropdown-menu dropdown-menu-align-right"> @@ -52,23 +50,21 @@ <a rel="nofollow" data-method="post" - :href='action.path' - > - <span v-html='svgs.iconPlay' aria-hidden="true"></span> + :href="action.path"> + <span v-html="svgs.iconPlay" aria-hidden="true"></span> <span>{{action.name}}</span> </a> </li> </ul> </div> - <div class="btn-group"> + + <div class="btn-group" v-if="artifacts"> <button - v-if='artifacts' class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" title="Artifacts" data-placement="top" data-toggle="dropdown" - aria-label="Artifacts" - > + aria-label="Artifacts"> <i class="fa fa-download" aria-hidden="true"></i> <i class="fa fa-caret-down" aria-hidden="true"></i> </button> @@ -76,42 +72,39 @@ <li v-for='artifact in pipeline.details.artifacts'> <a rel="nofollow" - download - :href='artifact.path' - > + :href="artifact.path"> <i class="fa fa-download" aria-hidden="true"></i> <span>{{download(artifact.name)}}</span> </a> </li> </ul> </div> - </div> - <div class="cancel-retry-btns inline"> - <a - v-if='pipeline.flags.retryable' - class="btn has-tooltip" - title="Retry" - rel="nofollow" - data-method="post" - data-placement="top" - data-toggle="dropdown" - :href='pipeline.retry_path' - aria-label="Retry"> - <i class="fa fa-repeat" aria-hidden="true"></i> - </a> - <a - v-if='pipeline.flags.cancelable' - @click="confirmAction" - class="btn btn-remove has-tooltip" - title="Cancel" - rel="nofollow" - data-method="post" - data-placement="top" - data-toggle="dropdown" - :href='pipeline.cancel_path' - aria-label="Cancel"> - <i class="fa fa-remove" aria-hidden="true"></i> - </a> + <div class="btn-group" v-if="pipeline.flags.retryable"> + <a + class="btn btn-default btn-retry has-tooltip" + title="Retry" + rel="nofollow" + data-method="post" + data-placement="top" + data-toggle="dropdown" + :href='pipeline.retry_path' + aria-label="Retry"> + <i class="fa fa-repeat" aria-hidden="true"></i> + </a> + </div> + <div class="btn-group" v-if="pipeline.flags.cancelable"> + <a + class="btn btn-remove has-tooltip" + title="Cancel" + rel="nofollow" + data-method="post" + data-placement="top" + data-toggle="dropdown" + :href='pipeline.cancel_path' + aria-label="Cancel"> + <i class="fa fa-remove" aria-hidden="true"></i> + </a> + </div> </div> </div> </td> diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 index 3598da11573..6048fa691dc 100644 --- a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 @@ -54,7 +54,7 @@ require('../lib/utils/datetime_utility'); }, }, template: ` - <td> + <td class="pipelines-time-ago"> <p class="duration" v-if='duration'> <span v-html='svgs.iconTimer'></span> {{duration}} @@ -65,8 +65,7 @@ require('../lib/utils/datetime_utility'); data-toggle="tooltip" data-placement="top" data-container="body" - :data-original-title='localTimeFinished' - > + :data-original-title='localTimeFinished'> {{timeStopped.words}} </time> </p> diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 0f9213b98e3..9a4129cdc8d 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -229,7 +229,7 @@ .controls { float: right; margin-top: 8px; - padding-bottom: 7px; + padding-bottom: 8px; border-bottom: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index e3da467a27c..d2be8dc7a39 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -26,6 +26,11 @@ .filtered-search-container { display: -webkit-flex; display: flex; + + @media (max-width: $screen-xs-min) { + -webkit-flex-direction: column; + flex-direction: column; + } } .filtered-search-input-container { @@ -34,6 +39,20 @@ position: relative; width: 100%; + @media (max-width: $screen-xs-min) { + -webkit-flex: 1 1 100%; + flex: 1 1 100%; + margin-bottom: 10px; + + .dropdown-menu { + width: auto; + left: 0; + right: 0; + max-width: none; + min-width: 100%; + } + } + .form-control { padding-left: 25px; padding-right: 25px; @@ -79,6 +98,31 @@ overflow: auto; } +@media (max-width: $screen-xs-min) { + .issues-details-filters { + padding: 0 0 10px; + background-color: $white-light; + border-top: 0; + } + + .filter-dropdown-container { + .dropdown-toggle, + .dropdown { + width: 100%; + } + + .dropdown { + margin-left: 0; + } + + .fa-chevron-down { + position: absolute; + right: 10px; + top: 10px; + } + } +} + %filter-dropdown-item-btn-hover { background-color: $dropdown-hover-color; color: $white-light; @@ -148,4 +192,4 @@ .filter-dropdown-loading { padding: 8px 16px; -} +}
\ No newline at end of file diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 6f2e746d4b0..9c76e58dfc8 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -20,6 +20,7 @@ $dark-highlight-bg: #ffe792; $dark-highlight-color: $black; $dark-pre-hll-bg: #373b41; $dark-hll-bg: #373b41; +$dark-over-bg: #9f9ab5; $dark-c: #969896; $dark-err: #c66; $dark-k: #b294bb; @@ -139,6 +140,18 @@ $dark-il: #de935f; } } + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $dark-over-bg; + border-color: darken($dark-over-bg, 5%); + + a { + color: darken($dark-over-bg, 15%); + } + } + } + .line_content.match { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 2144a5f7466..6488a099c74 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -13,6 +13,7 @@ $monokai-line-empty-bg: #49483e; $monokai-line-empty-border: darken($monokai-line-empty-bg, 15%); $monokai-diff-border: #808080; $monokai-highlight-bg: #ffe792; +$monokai-over-bg: #9f9ab5; $monokai-new-bg: rgba(166, 226, 46, 0.1); $monokai-new-idiff: rgba(166, 226, 46, 0.15); @@ -139,6 +140,18 @@ $monokai-gi: #a6e22e; } } + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $monokai-over-bg; + border-color: darken($monokai-over-bg, 5%); + + a { + color: darken($monokai-over-bg, 15%); + } + } + } + .line_content.match { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 2cb1d18f12f..00079cc2b5b 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -17,6 +17,7 @@ $solarized-dark-line-color-new: #5a766c; $solarized-dark-line-color-old: #7a6c71; $solarized-dark-highlight: #094554; $solarized-dark-hll-bg: #174652; +$solarized-dark-over-bg: #9f9ab5; $solarized-dark-c: #586e75; $solarized-dark-err: #93a1a1; $solarized-dark-g: #93a1a1; @@ -143,6 +144,18 @@ $solarized-dark-il: #2aa198; } } + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $solarized-dark-over-bg; + border-color: darken($solarized-dark-over-bg, 5%); + + a { + color: darken($solarized-dark-over-bg, 15%); + } + } + } + .line_content.match { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index b72c4326730..2e3b68f1a58 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -18,6 +18,7 @@ $solarized-light-line-color-new: #a1a080; $solarized-light-line-color-old: #ad9186; $solarized-light-highlight: #eee8d5; $solarized-light-hll-bg: #ddd8c5; +$solarized-light-over-bg: #ded7fc; $solarized-light-c: #93a1a1; $solarized-light-err: #586e75; $solarized-light-g: #586e75; @@ -150,6 +151,18 @@ $solarized-light-il: #2aa198; } } + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $solarized-light-over-bg; + border-color: darken($solarized-light-over-bg, 5%); + + a { + color: darken($solarized-light-over-bg, 15%); + } + } + } + .line_content.match { @include matchLine; } diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 398fbfd3b18..0eca30e570f 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -7,6 +7,7 @@ $white-code-color: $gl-text-color; $white-highlight: #fafe3d; $white-pre-hll-bg: #f8eec7; $white-hll-bg: #f8f8f8; +$white-over-bg: #ded7fc; $white-c: #998; $white-err: #a61717; $white-err-bg: #e3d2d2; @@ -123,6 +124,16 @@ $white-gc-bg: #eaf2f5; } } + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $white-over-bg; + border-color: darken($white-over-bg, 5%); + + a { + color: darken($white-over-bg, 15%); + } + } + &.hll:not(.empty-cell) { background-color: $line-number-select; border-color: $line-select-yellow-dark; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 92d7772da57..339cdcde480 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -89,6 +89,10 @@ .diff-line-num { width: 50px; + + a { + transition: none; + } } .line_holder td { @@ -109,10 +113,6 @@ td.line_content.parallel { width: 46%; } - - .add-diff-note { - margin-left: -65px; - } } .old_line, diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index aa130a1abb0..00f5f2645b3 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -452,36 +452,37 @@ ul.notes { * Line note button on the side of diffs */ -.diff-file tr.line_holder { - @mixin show-add-diff-note { - display: inline-block; - } +.add-diff-note { + display: none; + margin-top: -2px; + border-radius: 50%; + background: $white-light; + padding: 1px 5px; + font-size: 12px; + color: $gl-link-color; + margin-left: -55px; + position: absolute; + z-index: 10; + width: 23px; + height: 23px; + border: 1px solid $border-color; + transition: transform .1s ease-in-out; - .add-diff-note { - margin-top: -8px; - border-radius: 40px; - background: $white-light; - padding: 4px; - font-size: 16px; - color: $gl-link-color; - margin-left: -56px; - position: absolute; - z-index: 10; - width: 32px; - // "hide" it by default - display: none; + &:hover { + background: $gl-info; + color: $white-light; + transform: scale(1.15); + } - &:hover { - background: $gl-info; - color: $white-light; - @include show-add-diff-note; - } + &:active { + outline: 0; } +} - // "show" the icon also if we just hover somewhere over the line - &:hover > td { +.diff-file { + .is-over { .add-diff-note { - @include show-add-diff-note; + display: inline-block; } } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 3fe1eef307e..f4707f71208 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -13,21 +13,16 @@ white-space: nowrap; } - .commit-title { - margin: 0; - } - - .controls { - white-space: nowrap; + .table-holder { + width: 100%; + overflow: auto; } - .btn { - margin: 4px; + .commit-title { + margin: 0; } .table.ci-table { - min-width: 1200px; - table-layout: fixed; .label { margin-bottom: 3px; @@ -37,16 +32,72 @@ color: $black; } - .pipeline-date, - .pipeline-status { - width: 10%; + .stage-cell { + min-width: 130px; // Guarantees we show at least 4 stages in line + width: 20%; + } + + .pipelines-time-ago { + text-align: right; } - .pipeline-info, - .pipeline-commit, - .pipeline-stages, .pipeline-actions { - width: 20%; + padding-right: 0; + min-width: 170px; //Guarantees buttons don't break in several lines. + + .btn-default { + color: $gl-text-color-secondary; + } + + .btn.btn-retry:hover, + .btn.btn-retry:focus { + border-color: $gray-darkest; + background-color: $white-normal; + } + + svg path { + fill: $gl-text-color-secondary; + } + + .dropdown-menu { + max-height: 250px; + overflow-y: auto; + } + + .dropdown-toggle, + .dropdown-menu { + color: $gl-text-color-secondary; + + .fa { + color: $gl-text-color-secondary; + font-size: 14px; + } + + svg, + .fa { + margin-right: 0; + } + } + + .btn-group { + &.open { + .btn-default { + background-color: $white-normal; + border-color: $border-white-normal; + } + } + + .btn { + .icon-play { + height: 13px; + width: 12px; + } + } + } + + .tooltip { + white-space: nowrap; + } } } } @@ -61,27 +112,10 @@ } } -.content-list.pipelines .table-holder { - min-height: 300px; -} - -.pipeline-holder { - width: 100%; - overflow: auto; -} - .table.ci-table { - min-width: 900px; - - &.pipeline { - min-width: 650px; - } - - &.builds-page { - tr { - height: 71px; - } + &.builds-page tr { + height: 71px; } tr { @@ -94,12 +128,16 @@ padding: 10px 8px; } + td.environments-actions { + padding-right: 0; + } + td.stage-cell { padding: 10px 0; } .commit-link { - padding: 9px 8px 10px; + padding: 9px 8px 10px 2px; } } @@ -206,72 +244,8 @@ } } - .pipeline-actions { - min-width: 140px; - - .btn { - margin: 0; - color: $gl-text-color-secondary; - } - - .cancel-retry-btns { - vertical-align: middle; - - .btn:not(:first-child) { - margin-left: 8px; - } - } - - .dropdown-menu { - max-height: 250px; - overflow-y: auto; - } - - .dropdown-toggle, - .dropdown-menu { - color: $gl-text-color-secondary; - - .fa { - color: $gl-text-color-secondary; - font-size: 14px; - } - - svg, - .fa { - margin-right: 0; - } - } - - .btn-remove { - color: $white-light; - } - - .btn-group { - &.open { - .btn-default { - background-color: $white-normal; - border-color: $border-white-normal; - } - } - - .btn { - .icon-play { - height: 13px; - width: 12px; - } - } - } - - .tooltip { - white-space: nowrap; - } - } - - .build-link { - - a { - color: $gl-text-color; - } + .build-link a { + color: $gl-text-color; } .btn-group.open .dropdown-toggle { @@ -335,31 +309,8 @@ } .tab-pane { - &.pipelines { - .ci-table { - min-width: 900px; - } - - .content-list.pipelines { - overflow: auto; - } - - .stage { - max-width: 100px; - width: 100px; - } - - .pipeline-actions { - min-width: initial; - } - } - - &.builds { - .ci-table { - tr { - height: 71px; - } - } + &.builds .ci-table tr { + height: 71px; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 67110813abb..07b93430442 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -638,14 +638,6 @@ pre.light-well { margin: 0; } -.activity-filter-block { - .controls { - padding-bottom: 7px; - margin-top: 8px; - border-bottom: 1px solid $border-color; - } -} - .commits-search-form { .input-short { min-width: 200px; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 0821974aa93..3ccf2a9ce33 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -26,6 +26,23 @@ module IssuableActions private + def render_conflict_response + respond_to do |format| + format.html do + @conflict = true + render :edit + end + + format.json do + render json: { + errors: [ + "Someone edited this #{issuable.human_class_name} at the same time you did. Please refresh your browser and make sure your changes will not unintentionally remove theirs." + ] + }, status: 409 + end + end + end + def labels @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index e610ccaec96..2992568ae66 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -33,6 +33,7 @@ module ServiceParams :issues_url, :jira_issue_transition_id, :merge_requests_events, + :mock_service_url, :namespace, :new_issue_url, :notify, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index ca5e81100da..1151555b8fa 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -134,8 +134,7 @@ class Projects::IssuesController < Projects::ApplicationController end rescue ActiveRecord::StaleObjectError - @conflict = true - render :edit + render_conflict_response end def referenced_merge_requests diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d122c7fdcb2..53f30a24312 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -296,22 +296,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController def update @merge_request = MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request) - if @merge_request.valid? - respond_to do |format| - format.html do - redirect_to([@merge_request.target_project.namespace.becomes(Namespace), - @merge_request.target_project, @merge_request]) - end - format.json do - render json: @merge_request.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) + respond_to do |format| + format.html do + if @merge_request.valid? + redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request]) + else + render :edit end end - else - render "edit" + + format.json do + render json: @merge_request.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) + end end rescue ActiveRecord::StaleObjectError - @conflict = true - render :edit + render_conflict_response end def remove_wip diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 4c7c16d694c..195094730aa 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -34,7 +34,7 @@ module ButtonHelper content_tag (append_link ? :a : :span), protocol, class: klass, - href: (project.http_url_to_repo if append_link), + href: (project.http_url_to_repo(current_user) if append_link), data: { html: true, placement: placement, diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 735a355c25a..4befeacc135 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -241,7 +241,7 @@ module ProjectsHelper when 'ssh' project.ssh_url_to_repo else - project.http_url_to_repo + project.http_url_to_repo(current_user) end end diff --git a/app/models/event.rb b/app/models/event.rb index 4b8eac9accf..d7ca8e3c599 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -36,7 +36,7 @@ class Event < ActiveRecord::Base scope :code_push, -> { where(action: PUSHED) } scope :in_projects, ->(projects) do - where(project_id: projects).recent + where(project_id: projects.pluck(:id)).recent end scope :with_associations, -> { includes(:author, :project, project: :namespace).preload(:target) } diff --git a/app/models/project.rb b/app/models/project.rb index aedd5bedcb9..e06fc30dc8a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -869,8 +869,14 @@ class Project < ActiveRecord::Base url_to_repo end - def http_url_to_repo - "#{web_url}.git" + def http_url_to_repo(user = nil) + url = web_url + + if user + url.sub!(%r{\Ahttps?://}) { |protocol| "#{protocol}#{user.username}@" } + end + + "#{url}.git" end # Check if current branch name is marked as protected in the system diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb index 4ebc5318da1..c13538e9fea 100644 --- a/app/models/project_services/mattermost_service.rb +++ b/app/models/project_services/mattermost_service.rb @@ -15,10 +15,10 @@ class MattermostService < ChatNotificationService 'This service sends notifications about projects events to Mattermost channels.<br /> To set up this service: <ol> - <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation. </li> - <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event. </li> - <li>Paste the webhook <strong>URL</strong> into the field bellow. </li> - <li>Select events below to enable notifications. The channel and username are optional. </li> + <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation.</li> + <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event.</li> + <li>Paste the webhook <strong>URL</strong> into the field below.</li> + <li>Select events below to enable notifications. The <strong>Channel handle</strong> and <strong>Username</strong> fields are optional.</li> </ol>' end @@ -28,14 +28,14 @@ class MattermostService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: 'http://mattermost_host/hooks/...' }, - { type: 'text', name: 'username', placeholder: 'username' }, + { type: 'text', name: 'webhook', placeholder: 'e.g. http://mattermost_host/hooks/…' }, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_builds' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def default_channel_placeholder - "town-square" + "Channel handle (e.g. town-square)" end end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb new file mode 100644 index 00000000000..a8d581a1f67 --- /dev/null +++ b/app/models/project_services/mock_ci_service.rb @@ -0,0 +1,82 @@ +# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service +class MockCiService < CiService + ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze + + prop_accessor :mock_service_url + validates :mock_service_url, presence: true, url: true, if: :activated? + + def title + 'MockCI' + end + + def description + 'Mock an external CI' + end + + def self.to_param + 'mock_ci' + end + + def fields + [ + { type: 'text', + name: 'mock_service_url', + placeholder: 'http://localhost:4004' }, + ] + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + url = [mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}"] + + URI.join(*url).to_s + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + # + def commit_status(sha, ref) + response = HTTParty.get(commit_status_path(sha), verify: false) + read_commit_status(response) + rescue Errno::ECONNREFUSED + :error + end + + def commit_status_path(sha) + url = [mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}.json"] + + URI.join(*url).to_s + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'pending' + else + response['status'] + end + + if status.present? && ALLOWED_STATES.include?(status) + status + else + :error + end + end +end diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index f77d2d7c60b..da7496573ef 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -13,11 +13,11 @@ class SlackService < ChatNotificationService def help 'This service sends notifications about projects events to Slack channels.<br /> - To setup this service: + To set up this service: <ol> - <li><a href="https://slack.com/apps/A0F7XDUAZ-incoming-webhooks">Add an incoming webhook</a> in your Slack team. The default channel can be overridden for each event. </li> - <li>Paste the <strong>Webhook URL</strong> into the field below. </li> - <li>Select events below to enable notifications. The channel and username are optional. </li> + <li><a href="https://slack.com/apps/A0F7XDUAZ-incoming-webhooks">Add an incoming webhook</a> in your Slack team. The default channel can be overridden for each event.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications. The <strong>Channel name</strong> and <strong>Username</strong> fields are optional.</li> </ol>' end @@ -27,14 +27,14 @@ class SlackService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: 'https://hooks.slack.com/services/...' }, - { type: 'text', name: 'username', placeholder: 'username' }, + { type: 'text', name: 'webhook', placeholder: 'e.g. https://hooks.slack.com/services/…' }, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_builds' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def default_channel_placeholder - "#general" + "Channel name (e.g. general)" end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 1762118278a..0dbf246c3a4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -109,9 +109,7 @@ class Repository offset: offset, after: after, before: before, - # --follow doesn't play well with --skip. See: - # https://gitlab.com/gitlab-org/gitlab-ce/issues/3574#note_3040520 - follow: false, + follow: path.present?, skip_merges: skip_merges } diff --git a/app/models/service.rb b/app/models/service.rb index facaaf9b331..3ef4cbead10 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -210,7 +210,7 @@ class Service < ActiveRecord::Base end def self.available_services_names - %w[ + service_names = %w[ asana assembla bamboo @@ -238,6 +238,9 @@ class Service < ActiveRecord::Base slack teamcity ] + service_names << 'mock_ci' if Rails.env.development? + + service_names.sort_by(&:downcase) end def self.build_from_template(project_id, template) diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 2e2d7f884ac..497fdb09cdc 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -18,7 +18,8 @@ module Groups end group.children.each do |group| - DestroyService.new(group, current_user).async_execute + # This needs to be synchronous since the namespace gets destroyed below + DestroyService.new(group, current_user).execute end group.really_destroy! diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index 86f317dcd18..e84944ed411 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -27,10 +27,6 @@ class ArtifactUploader < GitlabUploader File.join(self.class.artifacts_cache_path, @build.artifacts_path) end - def file_storage? - self.class.storage == CarrierWave::Storage::File - end - def filename file.try(:filename) end diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index cfcb877cc3e..6aa1f5a8c50 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -4,6 +4,6 @@ class AttachmentUploader < GitlabUploader storage :file def store_dir - "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" + "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 265cea2d2c6..b4c393c6f2c 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -4,7 +4,7 @@ class AvatarUploader < GitlabUploader storage :file def store_dir - "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" + "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end def exists? diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 23b7318827c..0d2edaeff3b 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -4,15 +4,12 @@ class FileUploader < GitlabUploader storage :file - attr_accessor :project, :secret + attr_accessor :project + attr_reader :secret def initialize(project, secret = nil) @project = project - @secret = secret || self.class.generate_secret - end - - def base_dir - "uploads" + @secret = secret || generate_secret end def store_dir @@ -23,10 +20,6 @@ class FileUploader < GitlabUploader File.join(base_dir, 'tmp', @project.path_with_namespace, @secret) end - def secure_url - File.join("/uploads", @secret, file.filename) - end - def to_markdown to_h[:markdown] end @@ -35,17 +28,23 @@ class FileUploader < GitlabUploader filename = image_or_video? ? self.file.basename : self.file.filename escaped_filename = filename.gsub("]", "\\]") - markdown = "[#{escaped_filename}](#{self.secure_url})" + markdown = "[#{escaped_filename}](#{secure_url})" markdown.prepend("!") if image_or_video? || dangerous? { alt: filename, - url: self.secure_url, + url: secure_url, markdown: markdown } end - def self.generate_secret + private + + def generate_secret SecureRandom.hex end + + def secure_url + File.join('/uploads', @secret, file.filename) + end end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 02d7c601d6c..bd7de4ed562 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -1,4 +1,14 @@ class GitlabUploader < CarrierWave::Uploader::Base + def self.base_dir + 'uploads' + end + + delegate :base_dir, to: :class + + def file_storage? + self.class.storage == CarrierWave::Storage::File + end + # Reduce disk IO def move_to_cache true diff --git a/app/uploaders/uploader_helper.rb b/app/uploaders/uploader_helper.rb index bee311583ea..7635c20ab3a 100644 --- a/app/uploaders/uploader_helper.rb +++ b/app/uploaders/uploader_helper.rb @@ -27,6 +27,8 @@ module UploaderHelper extension_match?(DANGEROUS_EXT) end + private + def extension_match?(extensions) return false unless file @@ -40,8 +42,4 @@ module UploaderHelper extensions.include?(extension.downcase) end - - def file_storage? - self.class.storage == CarrierWave::Storage::File - end end diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index deb62845e1c..d4d166ab7b6 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -15,6 +15,8 @@ %td = runner.description %td + = runner.version + %td - if runner.shared? n/a - else diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index d725e477044..7d26864d0f3 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -67,6 +67,7 @@ %th Type %th Runner token %th Description + %th Version %th Projects %th Jobs %th Tags diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index dc76599b776..0dbb0ca6958 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -4,7 +4,7 @@ .nav-block - if current_user .controls - = link_to dashboard_projects_path(:atom, { private_token: current_user.private_token }), class: 'btn rss-btn' do + = link_to dashboard_projects_path(:atom, { private_token: current_user.private_token }), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do %i.fa.fa-rss = render 'shared/event_filter' diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 71cc4d87b1f..c442cf056c3 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -4,7 +4,7 @@ .nav-block - if current_user .controls - = link_to group_path(@group, format: :atom, private_token: current_user.private_token), class: 'btn rss-btn' do + = link_to group_path(@group, format: :atom, private_token: current_user.private_token), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do %i.fa.fa-rss = render 'shared/event_filter' diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 0ea733cb978..4268337fd6d 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -4,7 +4,7 @@ .nav-block.activity-filter-block - if current_user .controls - = link_to namespace_project_path(@project.namespace, @project, format: :atom, private_token: current_user.private_token), title: "Feed", class: 'btn rss-btn' do + = link_to namespace_project_path(@project.namespace, @project, format: :atom, private_token: current_user.private_token), title: "Subscribe", class: 'btn rss-btn has-tooltip' do = icon('rss') = render 'shared/event_filter' diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml deleted file mode 100644 index 3475fa5f960..00000000000 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ /dev/null @@ -1,92 +0,0 @@ -- status = pipeline.status -- show_commit = local_assigns.fetch(:show_commit, true) -- show_branch = local_assigns.fetch(:show_branch, true) - -%tr.commit - %td.commit-link - = render 'ci/status/badge', status: pipeline.detailed_status(current_user) - - %td - = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do - %span.pipeline-id ##{pipeline.id} - %span by - - if pipeline.user - = user_avatar(user: pipeline.user, size: 20) - - else - %span.api.monospace API - - if pipeline.latest? - %span.label.label-success.has-tooltip{ title: 'Latest pipeline for this branch' } latest - - if pipeline.triggered? - %span.label.label-primary triggered - - if pipeline.yaml_errors.present? - %span.label.label-danger.has-tooltip{ title: "#{pipeline.yaml_errors}" } yaml invalid - - if pipeline.builds.any?(&:stuck?) - %span.label.label-warning stuck - - %td.branch-commit - - if pipeline.ref && show_branch - .icon-container - = pipeline.tag? ? icon('tag') : icon('code-fork') - = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace branch-name" - - if show_commit - .icon-container.commit-icon - = custom_icon("icon_commit") - = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "commit-id monospace" - - %p.commit-title - - if commit = pipeline.commit - = author_avatar(commit, size: 20) - = link_to_gfm truncate(commit.title, length: 60, escape: false), namespace_project_commit_path(pipeline.project.namespace, pipeline.project, commit.id), class: "commit-row-message" - - else - Cant find HEAD commit for this branch - - %td - = render 'shared/mini_pipeline_graph', pipeline: pipeline, klass: 'js-mini-pipeline-graph' - - %td - - if pipeline.duration - %p.duration - = custom_icon("icon_timer") - = duration_in_numbers(pipeline.duration) - - if pipeline.finished_at - %p.finished-at - = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at, short_format: false)} - - %td.pipeline-actions.hidden-xs - .controls.pull-right - - artifacts = pipeline.builds.latest.with_artifacts_not_expired - - actions = pipeline.manual_actions - - if artifacts.present? || actions.any? - .btn-group.inline - - if actions.any? - .btn-group - %button.dropdown-toggle.btn.btn-default.has-tooltip.js-pipeline-dropdown-manual-actions{ type: 'button', title: 'Manual pipeline', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Manual pipeline' } - = custom_icon('icon_play') - = icon('caret-down', 'aria-hidden' => 'true') - %ul.dropdown-menu.dropdown-menu-align-right - - actions.each do |build| - %li - = link_to play_namespace_project_build_path(pipeline.project.namespace, pipeline.project, build), method: :post, rel: 'nofollow' do - = custom_icon('icon_play') - %span= build.name - - if artifacts.present? - .btn-group - %button.dropdown-toggle.btn.btn-default.build-artifacts.has-tooltip.js-pipeline-dropdown-download{ type: 'button', title: 'Artifacts', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Artifacts' } - = icon("download") - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - artifacts.each do |build| - %li - = link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow', download: '' do - = icon("download") - %span Download '#{build.name}' artifacts - - - if can?(current_user, :update_pipeline, pipeline.project) - .cancel-retry-btns.inline - - if pipeline.retryable? - = link_to retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn has-tooltip', title: 'Retry', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Retry' , method: :post do - = icon("repeat") - - if pipeline.cancelable? - = link_to cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-remove has-tooltip', title: 'Cancel', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Cancel' , method: :post do - = icon("remove") diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 8e04b50bb8a..62f09cc2dc1 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -82,7 +82,7 @@ %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} - .pull-right + .pull-right.filter-dropdown-container = render 'shared/sort_dropdown' - if @bulk_edit diff --git a/changelogs/unreleased/14748-runner-version-in-admin-views.yml b/changelogs/unreleased/14748-runner-version-in-admin-views.yml new file mode 100644 index 00000000000..2478a81c824 --- /dev/null +++ b/changelogs/unreleased/14748-runner-version-in-admin-views.yml @@ -0,0 +1,4 @@ +--- +title: Add runner version to /admin/runners view +merge_request: 8733 +author: Jonathon Reinhart diff --git a/changelogs/unreleased/1937-https-clone-url-username.yml b/changelogs/unreleased/1937-https-clone-url-username.yml new file mode 100644 index 00000000000..fa89d94e0f3 --- /dev/null +++ b/changelogs/unreleased/1937-https-clone-url-username.yml @@ -0,0 +1,4 @@ +--- +title: Add the Username to the HTTP(S) clone URL of a Repository +merge_request: 9347 +author: Jan Christophersen diff --git a/changelogs/unreleased/23062-allow-git-log-to-accept-follow-and-skip.yml b/changelogs/unreleased/23062-allow-git-log-to-accept-follow-and-skip.yml new file mode 100644 index 00000000000..f7c856040e0 --- /dev/null +++ b/changelogs/unreleased/23062-allow-git-log-to-accept-follow-and-skip.yml @@ -0,0 +1,4 @@ +--- +title: Make Git history follow renames again by performing the --skip in Ruby +merge_request: +author: diff --git a/changelogs/unreleased/27267-events-project-query-performance-regression.yml b/changelogs/unreleased/27267-events-project-query-performance-regression.yml new file mode 100644 index 00000000000..a1697b57eac --- /dev/null +++ b/changelogs/unreleased/27267-events-project-query-performance-regression.yml @@ -0,0 +1,4 @@ +--- +title: 'Add performance query regression fix for !9088 affecting #27267' +merge_request: +author: diff --git a/changelogs/unreleased/27840-improve-search-bar-experience.yml b/changelogs/unreleased/27840-improve-search-bar-experience.yml new file mode 100644 index 00000000000..87b1f0c5572 --- /dev/null +++ b/changelogs/unreleased/27840-improve-search-bar-experience.yml @@ -0,0 +1,4 @@ +--- +title: Enhanced filter issues layout for better mobile experiance +merge_request: 9280 +author: Pratik Borsadiya diff --git a/changelogs/unreleased/28212-avoid-dos-on-build-trace.yml b/changelogs/unreleased/28212-avoid-dos-on-build-trace.yml new file mode 100644 index 00000000000..800e0389c86 --- /dev/null +++ b/changelogs/unreleased/28212-avoid-dos-on-build-trace.yml @@ -0,0 +1,4 @@ +--- +title: Replace setInterval with setTimeout to prevent highly frequent requests +merge_request: 9271 +author: Takuya Noguchi diff --git a/changelogs/unreleased/feature-runners-registration-deletion-v4-api.yml b/changelogs/unreleased/feature-runners-registration-deletion-v4-api.yml new file mode 100644 index 00000000000..e646a6a17b7 --- /dev/null +++ b/changelogs/unreleased/feature-runners-registration-deletion-v4-api.yml @@ -0,0 +1,4 @@ +--- +title: Add Runner's registration/deletion v4 API +merge_request: 9246 +author: diff --git a/changelogs/unreleased/fix-mr-size-with-over-100-files.yml b/changelogs/unreleased/fix-mr-size-with-over-100-files.yml deleted file mode 100644 index eecf3c99a75..00000000000 --- a/changelogs/unreleased/fix-mr-size-with-over-100-files.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix MR changes tab size count when there are over 100 files in the diff -merge_request: -author: diff --git a/changelogs/unreleased/issue_24815.yml b/changelogs/unreleased/issue_24815.yml new file mode 100644 index 00000000000..916e47d36a9 --- /dev/null +++ b/changelogs/unreleased/issue_24815.yml @@ -0,0 +1,4 @@ +--- +title: Fix issuable stale object error handler for js when updating tasklists +merge_request: +author: diff --git a/changelogs/unreleased/mock-ci-service.yml b/changelogs/unreleased/mock-ci-service.yml new file mode 100644 index 00000000000..24c6366177f --- /dev/null +++ b/changelogs/unreleased/mock-ci-service.yml @@ -0,0 +1,4 @@ +--- +title: Add Mock CI service/integration for development +merge_request: +author: diff --git a/changelogs/unreleased/mr-diff-comment-button.yml b/changelogs/unreleased/mr-diff-comment-button.yml new file mode 100644 index 00000000000..1dc6ed1c495 --- /dev/null +++ b/changelogs/unreleased/mr-diff-comment-button.yml @@ -0,0 +1,4 @@ +--- +title: Improved diff comment button UX +merge_request: +author: diff --git a/changelogs/unreleased/rss-btn-alignment-fix.yml b/changelogs/unreleased/rss-btn-alignment-fix.yml new file mode 100644 index 00000000000..c8f57ec0b7c --- /dev/null +++ b/changelogs/unreleased/rss-btn-alignment-fix.yml @@ -0,0 +1,4 @@ +--- +title: Fixed RSS button alignment on activity pages +merge_request: +author: diff --git a/changelogs/unreleased/sh-bump-hashie-to-3-5-5.yml b/changelogs/unreleased/sh-bump-hashie-to-3-5-5.yml new file mode 100644 index 00000000000..57f1474093a --- /dev/null +++ b/changelogs/unreleased/sh-bump-hashie-to-3-5-5.yml @@ -0,0 +1,4 @@ +--- +title: Bump Hashie to 3.5.5 and omniauth to 1.4.2 to eliminate warning noise +merge_request: +author: diff --git a/changelogs/unreleased/ssh-key-paste.yml b/changelogs/unreleased/ssh-key-paste.yml new file mode 100644 index 00000000000..1e34ef60f6e --- /dev/null +++ b/changelogs/unreleased/ssh-key-paste.yml @@ -0,0 +1,4 @@ +--- +title: SSH key field updates title after pasting key +merge_request: +author: diff --git a/changelogs/unreleased/unified-member-api-response.yml b/changelogs/unreleased/unified-member-api-response.yml new file mode 100644 index 00000000000..0a60b4d46a3 --- /dev/null +++ b/changelogs/unreleased/unified-member-api-response.yml @@ -0,0 +1,4 @@ +--- +title: 'API: Return 400 for all validation erros in the mebers API' +merge_request: 9523 +author: Robert Schilling diff --git a/changelogs/unreleased/update-vue-2-1.yml b/changelogs/unreleased/update-vue-2-1.yml new file mode 100644 index 00000000000..acc42bf00b1 --- /dev/null +++ b/changelogs/unreleased/update-vue-2-1.yml @@ -0,0 +1,4 @@ +--- +title: update Vue to v2.1.10 +merge_request: 9386 +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index e754f68553a..a71ec0c5f52 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -87,7 +87,7 @@ var config = { 'bootstrap/js': 'bootstrap-sass/assets/javascripts/bootstrap', 'emoji-aliases$': path.join(ROOT_PATH, 'fixtures/emojis/aliases.json'), 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), - 'vue$': IS_PRODUCTION ? 'vue/dist/vue.min.js' : 'vue/dist/vue.js', + 'vue$': 'vue/dist/vue.common.js', } } } diff --git a/db/fixtures/development/19_nested_groups.rb b/db/fixtures/development/19_nested_groups.rb new file mode 100644 index 00000000000..d8dddc3fee9 --- /dev/null +++ b/db/fixtures/development/19_nested_groups.rb @@ -0,0 +1,69 @@ +require './spec/support/sidekiq' + +def create_group_with_parents(user, full_path) + parent_path = nil + group = nil + + until full_path.blank? + path, _, full_path = full_path.partition('/') + + if parent_path + parent = Group.find_by_full_path(parent_path) + + parent_path += '/' + parent_path += path + + group = Groups::CreateService.new(user, path: path, parent_id: parent.id).execute + else + parent_path = path + + group = Group.find_by_full_path(parent_path) || + Groups::CreateService.new(user, path: path).execute + end + end + + group +end + +Sidekiq::Testing.inline! do + Gitlab::Seeder.quiet do + project_urls = [ + 'https://android.googlesource.com/platform/hardware/broadcom/libbt.git', + 'https://android.googlesource.com/platform/hardware/broadcom/wlan.git', + 'https://android.googlesource.com/platform/hardware/bsp/bootloader/intel/edison-u-boot.git', + 'https://android.googlesource.com/platform/hardware/bsp/broadcom.git', + 'https://android.googlesource.com/platform/hardware/bsp/freescale.git', + 'https://android.googlesource.com/platform/hardware/bsp/imagination.git', + 'https://android.googlesource.com/platform/hardware/bsp/intel.git', + 'https://android.googlesource.com/platform/hardware/bsp/kernel/common/v4.1.git', + 'https://android.googlesource.com/platform/hardware/bsp/kernel/common/v4.4.git' + ] + + user = User.admins.first + + project_urls.each_with_index do |url, i| + full_path = url.sub('https://android.googlesource.com/', '') + full_path = full_path.sub(/\.git\z/, '') + full_path, _, project_path = full_path.rpartition('/') + group = Group.find_by_full_path(full_path) || create_group_with_parents(user, full_path) + + params = { + import_url: url, + namespace_id: group.id, + path: project_path, + name: project_path, + description: FFaker::Lorem.sentence, + visibility_level: Gitlab::VisibilityLevel.values.sample + } + + project = Projects::CreateService.new(user, params).execute + project.send(:_run_after_commit_queue) + + if project.valid? + print '.' + else + print 'F' + end + end + end +end diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index a6300e18dc0..28e413ef447 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -466,6 +466,46 @@ If Registry is enabled in your GitLab instance, but you don't need it for your project, you can disable it from your project's settings. Read the user guide on how to achieve that. +## Disable Container Registry but use GitLab as an auth endpoint + +You can disable the embedded Container Registry to use an external one, but +still use GitLab as an auth endpoint. + +**Omnibus GitLab** +1. Open `/etc/gitlab/gitlab.rb` and set necessary configurations: + + ```ruby + registry['enable'] = false + gitlab_rails['registry_enabled'] = true + gitlab_rails['registry_host'] = "registry.gitlab.example.com" + gitlab_rails['registry_port'] = "5005" + gitlab_rails['registry_api_url'] = "http://localhost:5000" + gitlab_rails['registry_key_path'] = "/var/opt/gitlab/gitlab-rails/certificate.key" + gitlab_rails['registry_path'] = "/var/opt/gitlab/gitlab-rails/shared/registry" + gitlab_rails['registry_issuer'] = "omnibus-gitlab-issuer" + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +**Installations from source** + +1. Open `/home/git/gitlab/config/gitlab.yml`, and edit the configuration settings under `registry`: + + ``` + ## Container Registry + + registry: + enabled: true + host: "registry.gitlab.example.com" + port: "5005" + api_url: "http://localhost:5000" + path: /var/opt/gitlab/gitlab-rails/shared/registry + key: /var/opt/gitlab/gitlab-rails/certificate.key + issuer: omnibus-gitlab-issuer + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + ## Storage limitations Currently, there is no storage limitation, which means a user can upload an diff --git a/doc/api/services.md b/doc/api/services.md index fba5da6587d..b030a425a7a 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -810,3 +810,38 @@ GET /projects/:id/services/teamcity [jira-doc]: ../user/project/integrations/jira.md [old-jira-api]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-13-stable/doc/api/services.md#jira + + +## MockCI + +Mock an external CI. See [`gitlab-org/gitlab-mock-ci-service`](https://gitlab.com/gitlab-org/gitlab-mock-ci-service) for an example of a companion mock service. + +This service is only available when your environment is set to development. + +### Create/Edit MockCI service + +Set MockCI service for a project. + +``` +PUT /projects/:id/services/mock-ci +``` + +Parameters: + +- `mock_service_url` (**required**) - http://localhost:4004 + +### Delete MockCI service + +Delete MockCI service for a project. + +``` +DELETE /projects/:id/services/mock-ci +``` + +### Get MockCI service settings + +Get MockCI service settings for a project. + +``` +GET /projects/:id/services/mock-ci +``` diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index 8af041be234..c178e224cc5 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -41,5 +41,6 @@ changes are in V4: - Renamed `branch_name` to `branch` on DELETE `id/repository/branches/:branch` response [!8936](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8936) - Remove `public` param from create and edit actions of projects [!8736](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8736) - Notes do not return deprecated field `upvote` and `downvote` [!9384](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9384) +- Return HTTP status code `400` for all validation errors when creating or updating a member instead of sometimes `422` error. [!9523](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9523) - Remove `GET /groups/owned`. Use `GET /groups?owned=true` instead [!9505](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9505) -- Return 202 with JSON body on async removals on V4 API (DELETE `/projects/:id/repository/merged_branches` and DELETE `/projects/:id`) [!9449](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9449)
\ No newline at end of file +- Return 202 with JSON body on async removals on V4 API (DELETE `/projects/:id/repository/merged_branches` and DELETE `/projects/:id`) [!9449](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9449) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index dd3ba1283f8..a586b095ef5 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1018,7 +1018,7 @@ A simple example: ```yaml job1: - coverage: /Code coverage: \d+\.\d+/ + coverage: '/Code coverage: \d+\.\d+/' ``` ## Git Strategy diff --git a/doc/development/ci_setup.md b/doc/development/ci_setup.md index 2f49b3564ab..b03216fec95 100644 --- a/doc/development/ci_setup.md +++ b/doc/development/ci_setup.md @@ -2,11 +2,12 @@ This document describes what services we use for testing GitLab and GitLab CI. -We currently use three CI services to test GitLab: +We currently use four CI services to test GitLab: 1. GitLab CI on [GitHost.io](https://gitlab-ce.githost.io/projects/4/) for the [GitLab.com repo](https://gitlab.com/gitlab-org/gitlab-ce) 2. GitLab CI at ci.gitlab.org to test the private GitLab B.V. repo at dev.gitlab.org 3. [Semephore](https://semaphoreapp.com/gitlabhq/gitlabhq/) for [GitHub.com repo](https://github.com/gitlabhq/gitlabhq) +4. [Mock CI Service](user/project/integrations/mock_ci.md) for local development | Software @ configuration being tested | GitLab CI (ci.gitlab.org) | GitLab CI (GitHost.io) | Semaphore | |---------------------------------------|---------------------------|---------------------------------------------------------------------------|-----------| diff --git a/doc/user/project/integrations/img/mattermost_configuration.png b/doc/user/project/integrations/img/mattermost_configuration.png Binary files differindex 3c5ff5ee317..f52acf4ef3b 100644 --- a/doc/user/project/integrations/img/mattermost_configuration.png +++ b/doc/user/project/integrations/img/mattermost_configuration.png diff --git a/doc/user/project/integrations/img/slack_configuration.png b/doc/user/project/integrations/img/slack_configuration.png Binary files differindex fc8e58e686b..527824fc3eb 100644 --- a/doc/user/project/integrations/img/slack_configuration.png +++ b/doc/user/project/integrations/img/slack_configuration.png diff --git a/doc/user/project/integrations/mattermost.md b/doc/user/project/integrations/mattermost.md index 09ba9994d3a..cfb0931273d 100644 --- a/doc/user/project/integrations/mattermost.md +++ b/doc/user/project/integrations/mattermost.md @@ -24,23 +24,24 @@ There, you will see a checkbox with the following events that can be triggered: - Push - Issue +- Confidential issue - Merge request - Note - Tag push - Build +- Pipeline - Wiki page -Bellow each of these event checkboxes, you will have an input field to insert -which Mattermost channel you want to send that event message, with `#town-square` -being the default. The hash sign is optional. +Below each of these event checkboxes, you have an input field to enter +which Mattermost channel you want to send that event message. Enter your preferred channel handle (the hash sign `#` is optional). At the end, fill in your Mattermost details: | Field | Description | | ----- | ----------- | -| **Webhook** | The incoming webhooks which you have to setup on Mattermost, it will be something like: http://mattermost.example/hooks/5xo... | +| **Webhook** | The incoming webhook URL which you have to setup on Mattermost, it will be something like: http://mattermost.example/hooks/5xo… | | **Username** | Optional username which can be on messages sent to Mattermost. Fill this in if you want to change the username of the bot. | | **Notify only broken builds** | If you choose to enable the **Build** event and you want to be only notified about failed builds. | - +| **Notify only broken pipelines** | If you choose to enable the **Pipeline** event and you want to be only notified about failed pipelines. | ![Mattermost configuration](img/mattermost_configuration.png) diff --git a/doc/user/project/integrations/mock_ci.md b/doc/user/project/integrations/mock_ci.md new file mode 100644 index 00000000000..6aefe5dbded --- /dev/null +++ b/doc/user/project/integrations/mock_ci.md @@ -0,0 +1,13 @@ +# Mock CI Service + +**NB: This service is only listed if you are in a development environment!** + +To setup the mock CI service server, respond to the following endpoints + +- `commit_status`: `#{project.namespace.path}/#{project.path}/status/#{sha}.json` + - Have your service return `200 { status: ['failed'|'canceled'|'running'|'pending'|'success'|'success_with_warnings'|'skipped'|'not_found'] }` + - If the service returns a 404, it is interpreted as `pending` +- `build_page`: `#{project.namespace.path}/#{project.path}/status/#{sha}` + - Just where the build is linked to, doesn't matter if implemented + +For an example of a mock CI server, see [`gitlab-org/gitlab-mock-ci-service`](https://gitlab.com/gitlab-org/gitlab-mock-ci-service) diff --git a/doc/user/project/integrations/slack.md b/doc/user/project/integrations/slack.md index 57a9492044b..f27f9a726fc 100644 --- a/doc/user/project/integrations/slack.md +++ b/doc/user/project/integrations/slack.md @@ -21,23 +21,25 @@ There, you will see a checkbox with the following events that can be triggered: - Push - Issue +- Confidential issue - Merge request - Note - Tag push - Build +- Pipeline - Wiki page -Bellow each of these event checkboxes, you will have an input field to insert -which Slack channel you want to send that event message, with `#general` -being the default. Enter your preferred channel **without** the hash sign (`#`). +Below each of these event checkboxes, you have an input field to enter +which Slack channel you want to send that event message. Enter your preferred channel name **without** the hash sign (`#`). At the end, fill in your Slack details: | Field | Description | | ----- | ----------- | | **Webhook** | The [incoming webhook URL][slackhook] which you have to setup on Slack. | -| **Username** | Optional username which can be on messages sent to slack. Fill this in if you want to change the username of the bot. | +| **Username** | Optional username which can be on messages sent to Slack. Fill this in if you want to change the username of the bot. | | **Notify only broken builds** | If you choose to enable the **Build** event and you want to be only notified about failed builds. | +| **Notify only broken pipelines** | If you choose to enable the **Pipeline** event and you want to be only notified about failed pipelines. | After you are all done, click **Save changes** for the changes to take effect. diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md index 8c5020bee37..9cc45065eb2 100644 --- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md +++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md @@ -63,6 +63,12 @@ git commit -am "Added Debian iso" # commit the file meta data git push origin master # sync the git repo and large file to the GitLab server ``` +>**Note**: Make sure that `.gitattributes` is tracked by git. Otherwise Git + LFS will not be working properly for people cloning the project. + ```bash + git add .gitattributes + ``` + Cloning the repository works the same as before. Git automatically detects the LFS-tracked files and clones them via HTTP. If you performed the git clone command with a SSH URL, you have to enter your GitLab credentials for HTTP diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb index 2b4a5ab0864..7dc33ab5683 100644 --- a/features/steps/explore/projects.rb +++ b/features/steps/explore/projects.rb @@ -49,7 +49,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps step 'I should see an http link to the repository' do project = Project.find_by(name: 'Community') - expect(page).to have_field('project_clone', with: project.http_url_to_repo) + expect(page).to have_field('project_clone', with: project.http_url_to_repo(@user)) end step 'I should see an ssh link to the repository' do diff --git a/lib/api/api.rb b/lib/api/api.rb index dc732012a33..7aa95a4a3c1 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -91,6 +91,7 @@ module API mount ::API::Projects mount ::API::ProjectSnippets mount ::API::Repositories + mount ::API::Runner mount ::API::Runners mount ::API::Services mount ::API::Session diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 0e37f40a887..a99d9cadc8a 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -618,6 +618,10 @@ module API end end + class RunnerRegistrationDetails < Grape::Entity + expose :id, :token + end + class BuildArtifactFile < Grape::Entity expose :filename, :size end diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb new file mode 100644 index 00000000000..119ca81b883 --- /dev/null +++ b/lib/api/helpers/runner.rb @@ -0,0 +1,23 @@ +module API + module Helpers + module Runner + def runner_registration_token_valid? + ActiveSupport::SecurityUtils.variable_size_secure_compare(params[:token], + current_application_settings.runners_registration_token) + end + + def get_runner_version_from_params + return unless params['info'].present? + attributes_for_keys(%w(name version revision platform architecture), params['info']) + end + + def authenticate_runner! + forbidden! unless current_runner + end + + def current_runner + @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s) + end + end + end +end diff --git a/lib/api/members.rb b/lib/api/members.rb index 8360c007005..5f6913d1a27 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -55,7 +55,6 @@ module API authorize_admin_source!(source_type, source) member = source.members.find_by(user_id: params[:user_id]) - conflict!('Member already exists') if member member = source.add_user(params[:user_id], params[:access_level], current_user: current_user, expires_at: params[:expires_at]) @@ -63,9 +62,6 @@ module API if member.persisted? && member.valid? present member.user, with: Entities::Member, member: member else - # This is to ensure back-compatibility but 400 behavior should be used - # for all validation errors in 9.0! - render_api_error!('Access level is not known', 422) if member.errors.key?(:access_level) render_validation_error!(member) end end @@ -87,9 +83,6 @@ module API if member.update_attributes(declared_params(include_missing: false)) present member.user, with: Entities::Member, member: member else - # This is to ensure back-compatibility but 400 behavior should be used - # for all validation errors in 9.0! - render_api_error!('Access level is not known', 422) if member.errors.key?(:access_level) render_validation_error!(member) end end diff --git a/lib/api/runner.rb b/lib/api/runner.rb new file mode 100644 index 00000000000..804b27d40a7 --- /dev/null +++ b/lib/api/runner.rb @@ -0,0 +1,52 @@ +module API + class Runner < Grape::API + helpers ::API::Helpers::Runner + + resource :runners do + desc 'Registers a new Runner' do + success Entities::RunnerRegistrationDetails + http_codes [[201, 'Runner was created'], [403, 'Forbidden']] + end + params do + requires :token, type: String, desc: 'Registration token' + optional :description, type: String, desc: %q(Runner's description) + optional :info, type: Hash, desc: %q(Runner's metadata) + optional :locked, type: Boolean, desc: 'Should Runner be locked for current project' + optional :run_untagged, type: Boolean, desc: 'Should Runner handle untagged jobs' + optional :tag_list, type: Array[String], desc: %q(List of Runner's tags) + end + post '/' do + attributes = attributes_for_keys [:description, :locked, :run_untagged, :tag_list] + + runner = + if runner_registration_token_valid? + # Create shared runner. Requires admin access + Ci::Runner.create(attributes.merge(is_shared: true)) + elsif project = Project.find_by(runners_token: params[:token]) + # Create a specific runner for project. + project.runners.create(attributes) + end + + return forbidden! unless runner + + if runner.id + runner.update(get_runner_version_from_params) + present runner, with: Entities::RunnerRegistrationDetails + else + not_found! + end + end + + desc 'Deletes a registered Runner' do + http_codes [[200, 'Runner was deleted'], [403, 'Forbidden']] + end + params do + requires :token, type: String, desc: %q(Runner's authentication token) + end + delete '/' do + authenticate_runner! + Ci::Runner.find_by_token(params[:token]).destroy + end + end + end +end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1456fe4688b..ad856115485 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -563,7 +563,20 @@ module API SlackService, MattermostService, TeamcityService, - ].freeze + ] + + if Rails.env.development? + services['mock-ci'] = [ + { + required: true, + name: :mock_service_url, + type: String, + desc: 'URL to the mock service' + } + ] + + service_classes << MockCiService + end trigger_services = { 'mattermost-slash-commands' => [ diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index b8354d7bf04..8ec90885231 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -324,24 +324,30 @@ module Gitlab end def log_by_shell(sha, options) - cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} log) - cmd += %W(-n #{options[:limit].to_i}) - cmd += %w(--format=%H) - cmd += %W(--skip=#{options[:offset].to_i}) - cmd += %w(--follow) if options[:follow] - cmd += %w(--no-merges) if options[:skip_merges] - cmd += %W(--after=#{options[:after].iso8601}) if options[:after] - cmd += %W(--before=#{options[:before].iso8601}) if options[:before] - cmd += [sha] - cmd += %W(-- #{options[:path]}) if options[:path].present? - - raw_output = IO.popen(cmd) {|io| io.read } - - log = raw_output.lines.map do |c| - Rugged::Commit.new(rugged, c.strip) - end - - log.is_a?(Array) ? log : [] + limit = options[:limit].to_i + offset = options[:offset].to_i + use_follow_flag = options[:follow] && options[:path].present? + + # We will perform the offset in Ruby because --follow doesn't play well with --skip. + # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/3574#note_3040520 + offset_in_ruby = use_follow_flag && options[:offset].present? + limit += offset if offset_in_ruby + + cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} log] + cmd << "--max-count=#{limit}" + cmd << '--format=%H' + cmd << "--skip=#{offset}" unless offset_in_ruby + cmd << '--follow' if use_follow_flag + cmd << '--no-merges' if options[:skip_merges] + cmd << "--after=#{options[:after].iso8601}" if options[:after] + cmd << "--before=#{options[:before].iso8601}" if options[:before] + cmd << sha + cmd += %W[-- #{options[:path]}] if options[:path].present? + + raw_output = IO.popen(cmd) { |io| io.read } + lines = offset_in_ruby ? raw_output.lines.drop(offset) : raw_output.lines + + lines.map! { |c| Rugged::Commit.new(rugged, c.strip) } end def sha_from_ref(ref) diff --git a/lib/gitlab/middleware/webpack_proxy.rb b/lib/gitlab/middleware/webpack_proxy.rb index 3fe32adeade..cb60c5f96ba 100644 --- a/lib/gitlab/middleware/webpack_proxy.rb +++ b/lib/gitlab/middleware/webpack_proxy.rb @@ -8,16 +8,16 @@ module Gitlab @proxy_host = opts.fetch(:proxy_host, 'localhost') @proxy_port = opts.fetch(:proxy_port, 3808) @proxy_path = opts[:proxy_path] if opts[:proxy_path] - super(app, opts) + + super(app, backend: "http://#{proxy_host}:#{proxy_port}", **opts) end def perform_request(env) - unless @proxy_path && env['PATH_INFO'].start_with?("/#{@proxy_path}") - return @app.call(env) + if @proxy_path && env['PATH_INFO'].start_with?("/#{@proxy_path}") + super(env) + else + @app.call(env) end - - env['HTTP_HOST'] = "#{@proxy_host}:#{@proxy_port}" - super(env) end end end diff --git a/package.json b/package.json index 66aa7e9fe5d..5528303ab21 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "stats-webpack-plugin": "^0.4.3", "timeago.js": "^2.0.5", "underscore": "^1.8.3", - "vue": "^2.0.3", + "vue": "^2.1.10", "vue-resource": "^0.9.3", "webpack": "^2.2.1", "webpack-bundle-analyzer": "^2.3.0" diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index e493b9396f6..46c758b4654 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -125,14 +125,16 @@ describe Projects::IssuesController do end describe 'PUT #update' do + before do + sign_in(user) + project.team << [user, :developer] + end + + it_behaves_like 'update invalid issuable', Issue + context 'when moving issue to another private project' do let(:another_project) { create(:empty_project, :private) } - before do - sign_in(user) - project.team << [user, :developer] - end - context 'when user has access to move issue' do before { another_project.team << [user, :reporter] } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index d9cb429132f..1ced666bb36 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -255,6 +255,8 @@ describe Projects::MergeRequestsController do expect { merge_request.reload.target_branch }.not_to change { merge_request.target_branch } end + + it_behaves_like 'update invalid issuable', MergeRequest end end diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index e8e080ce3e2..273cacd82cd 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -32,7 +32,7 @@ feature 'Admin disables Git access protocol', feature: true do scenario 'shows only HTTP url' do visit_project - expect(page).to have_content("git clone #{project.http_url_to_repo}") + expect(page).to have_content("git clone #{project.http_url_to_repo(admin)}") expect(page).not_to have_selector('#clone-dropdown') end end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 1e0db4a0499..1c8267b1593 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe 'Issues', feature: true do + include DropzoneHelper include IssueHelpers include SortingHelper include WaitForAjax @@ -570,19 +571,13 @@ describe 'Issues', feature: true do end it 'uploads file when dragging into textarea' do - drop_in_dropzone test_image_file - - # Wait for the file to upload - sleep 1 + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') expect(page.find_field("issue_description").value).to have_content 'banana_sample' end it 'adds double newline to end of attachment markdown' do - drop_in_dropzone test_image_file - - # Wait for the file to upload - sleep 1 + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') expect(page.find_field("issue_description").value).to match /\n\n$/ end @@ -665,25 +660,4 @@ describe 'Issues', feature: true do end end end - - def drop_in_dropzone(file_path) - # Generate a fake input selector - page.execute_script <<-JS - var fakeFileInput = window.$('<input/>').attr( - {id: 'fakeFileInput', type: 'file'} - ).appendTo('body'); - JS - # Attach the file to the fake input selector with Capybara - attach_file("fakeFileInput", file_path) - # Add the file to a fileList array and trigger the fake drop event - page.execute_script <<-JS - var fileList = [$('#fakeFileInput')[0].files[0]]; - var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); - $('.div-dropzone')[0].dropzone.listeners[0].events.drop(e); - JS - end - - def test_image_file - File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') - end end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index eb1050d21c6..2f436f153aa 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -15,7 +15,7 @@ feature 'Profile > SSH Keys', feature: true do scenario 'auto-populates the title', js: true do fill_in('Key', with: attributes_for(:key).fetch(:key)) - expect(find_field('Title').value).to eq 'dummy@gitlab.com' + expect(page).to have_field("Title", with: "dummy@gitlab.com") end scenario 'saves the new key' do diff --git a/spec/features/projects/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/developer_views_empty_project_instructions_spec.rb index 0c51fe72ca4..2352329d58c 100644 --- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb +++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb @@ -56,8 +56,14 @@ feature 'Developer views empty project instructions', feature: true do end def expect_instructions_for(protocol) - msg = :"#{protocol.downcase}_url_to_repo" - - expect(page).to have_content("git clone #{project.send(msg)}") + url = + case protocol + when 'ssh' + project.ssh_url_to_repo + when 'http' + project.http_url_to_repo(developer) + end + + expect(page).to have_content("git clone #{url}") end end diff --git a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb new file mode 100644 index 00000000000..f88a515f7fc --- /dev/null +++ b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +feature 'User uploads avatar to group', feature: true do + scenario 'they see the new avatar' do + user = create(:user) + group = create(:group) + group.add_owner(user) + login_as(user) + + visit edit_group_path(group) + attach_file( + 'group_avatar', + Rails.root.join('spec', 'fixtures', 'dk.png'), + visible: false + ) + + click_button 'Save group' + + visit group_path(group) + + expect(page).to have_selector(%Q(img[src$="/uploads/group/avatar/#{group.id}/dk.png"])) + + # Cheating here to verify something that isn't user-facing, but is important + expect(group.reload.avatar.file).to exist + end +end diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb new file mode 100644 index 00000000000..0dfd29045e5 --- /dev/null +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +feature 'User uploads avatar to profile', feature: true do + scenario 'they see their new avatar' do + user = create(:user) + login_as(user) + + visit profile_path + attach_file( + 'user_avatar', + Rails.root.join('spec', 'fixtures', 'dk.png'), + visible: false + ) + + click_button 'Update profile settings' + + visit user_path(user) + + expect(page).to have_selector(%Q(img[src$="/uploads/user/avatar/#{user.id}/dk.png"])) + + # Cheating here to verify something that isn't user-facing, but is important + expect(user.reload.avatar.file).to exist + end +end diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb new file mode 100644 index 00000000000..0c160dd74b4 --- /dev/null +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +feature 'User uploads file to note', feature: true do + include DropzoneHelper + + let(:user) { create(:user) } + let(:project) { create(:empty_project, creator: user, namespace: user.namespace) } + + scenario 'they see the attached file', js: true do + issue = create(:issue, project: project, author: user) + + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + + dropzone_file(Rails.root.join('spec', 'fixtures', 'dk.png')) + click_button 'Comment' + wait_for_ajax + + expect(find('a.no-attachment-icon img[alt="dk"]')['src']) + .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 766f16bb824..1919542ca25 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -529,7 +529,7 @@ describe Gitlab::Git::Repository, seed_helper: true do commit_with_new_name = nil rename_commit = nil - before(:all) do + before(:context) do # Add new commits so that there's a renamed file in the commit history repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged @@ -538,49 +538,119 @@ describe Gitlab::Git::Repository, seed_helper: true do commit_with_new_name = new_commit_edit_new_file(repo) end + after(:context) do + # Erase our commits so other tests get the original repo + repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged + repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) + end + context "where 'follow' == true" do - options = { ref: "master", follow: true } + let(:options) { { ref: "master", follow: true } } context "and 'path' is a directory" do - let(:log_commits) do - repository.log(options.merge(path: "encoding")) - end + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "encoding")) - it "should not follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_old_name) + aggregate_failures do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).not_to include(commit_with_old_name) + end end end context "and 'path' is a file that matches the new filename" do - let(:log_commits) do - repository.log(options.merge(path: "encoding/CHANGELOG")) + context 'without offset' do + it "follows renames" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG")) + + aggregate_failures do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end - it "should follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + context 'with offset=1' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end + end + + context 'with offset=1', 'and limit=1' do + it "follows renames, skip the latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1)) + + expect(log_commits).to contain_exactly(rename_commit) + end + end + + context 'with offset=1', 'and limit=2' do + it "follows renames, skip the latest commit and return only two commits" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2)) + + aggregate_failures do + expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name) + end + end + end + + context 'with offset=2' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end + end + + context 'with offset=2', 'and limit=1' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1)) + + expect(log_commits).to contain_exactly(commit_with_old_name) + end + end + + context 'with offset=2', 'and limit=2' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end end context "and 'path' is a file that matches the old filename" do - let(:log_commits) do - repository.log(options.merge(path: "CHANGELOG")) - end + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "CHANGELOG")) - it "should not follow renames" do - expect(log_commits).to include(commit_with_old_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_new_name) + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end end end context "unknown ref" do - let(:log_commits) { repository.log(options.merge(ref: 'unknown')) } + it "returns an empty array" do + log_commits = repository.log(options.merge(ref: 'unknown')) - it "should return empty" do expect(log_commits).to eq([]) end end @@ -699,12 +769,6 @@ describe Gitlab::Git::Repository, seed_helper: true do end end end - - after(:all) do - # Erase our commits so other tests get the original repo - repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged - repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) - end end describe "#commits_between" do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 5232c531635..ee4f4092062 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1894,4 +1894,25 @@ describe Project, models: true do end end end + + describe '#http_url_to_repo' do + let(:project) { create :empty_project } + + context 'when no user is given' do + it 'returns the url to the repo without a username' do + url = project.http_url_to_repo + + expect(url).to eq(project.http_url_to_repo) + expect(url).not_to include('@') + end + end + + context 'when user is given' do + it 'returns the url to the repo with the username' do + user = build_stubbed(:user) + + expect(project.http_url_to_repo(user)).to match(%r{https?:\/\/#{user.username}@}) + end + end + end end diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 31166b50033..127498ed109 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -173,11 +173,11 @@ describe API::Members, api: true do expect(response).to have_http_status(400) end - it 'returns 422 when access_level is not valid' do + it 'returns 400 when access_level is not valid' do post api("/#{source_type.pluralize}/#{source.id}/members", master), user_id: stranger.id, access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -230,11 +230,11 @@ describe API::Members, api: true do expect(response).to have_http_status(400) end - it 'returns 422 when access level is not valid' do + it 'returns 400 when access level is not valid' do put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -342,7 +342,7 @@ describe API::Members, api: true do post api("/projects/#{project.id}/members", master), user_id: stranger.id, access_level: Member::OWNER - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end.to change { project.members.count }.by(0) end end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb new file mode 100644 index 00000000000..73e82647ca0 --- /dev/null +++ b/spec/requests/api/runner_spec.rb @@ -0,0 +1,148 @@ +require 'spec_helper' + +describe API::Runner do + include ApiHelpers + include StubGitlabCalls + + let(:registration_token) { 'abcdefg123456' } + + before do + stub_gitlab_calls + stub_application_setting(runners_registration_token: registration_token) + end + + describe '/api/v4/runners' do + describe 'POST /api/v4/runners' do + context 'when no token is provided' do + it 'returns 400 error' do + post api('/runners') + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + post api('/runners'), token: 'invalid' + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + it 'creates runner with default values' do + post api('/runners'), token: registration_token + + runner = Ci::Runner.first + + expect(response).to have_http_status 201 + expect(json_response['id']).to eq(runner.id) + expect(json_response['token']).to eq(runner.token) + expect(runner.run_untagged).to be true + end + + context 'when project token is used' do + let(:project) { create(:empty_project) } + + it 'creates runner' do + post api('/runners'), token: project.runners_token + + expect(response).to have_http_status 201 + expect(project.runners.size).to eq(1) + end + end + end + + context 'when runner description is provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + description: 'server.hostname' + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.description).to eq('server.hostname') + end + end + + context 'when runner tags are provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + tag_list: 'tag1, tag2' + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2)) + end + end + + context 'when option for running untagged jobs is provided' do + context 'when tags are provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + run_untagged: false, + tag_list: ['tag'] + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.run_untagged).to be false + expect(Ci::Runner.first.tag_list.sort).to eq(['tag']) + end + end + + context 'when tags are not provided' do + it 'returns 404 error' do + post api('/runners'), token: registration_token, + run_untagged: false + + expect(response).to have_http_status 404 + end + end + end + + context 'when option for locking Runner is provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + locked: true + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.locked).to be true + end + end + + %w(name version revision platform architecture).each do |param| + context "when info parameter '#{param}' info is present" do + let(:value) { "#{param}_value" } + + it %q(updates provided Runner's parameter) do + post api('/runners'), token: registration_token, + info: { param => value } + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.read_attribute(param.to_sym)).to eq(value) + end + end + end + end + + describe 'DELETE /api/v4/runners' do + context 'when no token is provided' do + it 'returns 400 error' do + delete api('/runners') + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + delete api('/runners'), token: 'invalid' + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + let(:runner) { create(:ci_runner) } + + it 'deletes Runner' do + delete api('/runners'), token: runner.token + expect(response).to have_http_status 200 + expect(Ci::Runner.count).to eq(0) + end + end + end + end +end diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index 32c2ed8cae7..98c560ffb26 100644 --- a/spec/services/groups/destroy_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -5,6 +5,7 @@ describe Groups::DestroyService, services: true do let!(:user) { create(:user) } let!(:group) { create(:group) } + let!(:nested_group) { create(:group, parent: group) } let!(:project) { create(:project, namespace: group) } let!(:gitlab_shell) { Gitlab::Shell.new } let!(:remove_path) { group.path + "+#{group.id}+deleted" } @@ -20,6 +21,7 @@ describe Groups::DestroyService, services: true do end it { expect(Group.unscoped.all).not_to include(group) } + it { expect(Group.unscoped.all).not_to include(nested_group) } it { expect(Project.unscoped.all).not_to include(project) } end diff --git a/spec/support/dropzone_helper.rb b/spec/support/dropzone_helper.rb new file mode 100644 index 00000000000..984ec7d2741 --- /dev/null +++ b/spec/support/dropzone_helper.rb @@ -0,0 +1,37 @@ +module DropzoneHelper + # Provides a way to perform `attach_file` for a Dropzone-based file input + # + # This is accomplished by creating a standard HTML file input on the page, + # performing `attach_file` on that field, and then triggering the appropriate + # Dropzone events to perform the actual upload. + # + # This method waits for the upload to complete before returning. + def dropzone_file(file_path) + # Generate a fake file input that Capybara can attach to + page.execute_script <<-JS.strip_heredoc + var fakeFileInput = window.$('<input/>').attr( + {id: 'fakeFileInput', type: 'file'} + ).appendTo('body'); + + window._dropzoneComplete = false; + JS + + # Attach the file to the fake input selector with Capybara + attach_file('fakeFileInput', file_path) + + # Manually trigger a Dropzone "drop" event with the fake input's file list + page.execute_script <<-JS.strip_heredoc + var fileList = [$('#fakeFileInput')[0].files[0]]; + var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); + + var dropzone = $('.div-dropzone')[0].dropzone; + dropzone.on('queuecomplete', function() { + window._dropzoneComplete = true; + }); + dropzone.listeners[0].events.drop(e); + JS + + # Wait until Dropzone's fired `queuecomplete` + loop until page.evaluate_script('window._dropzoneComplete === true') + end +end diff --git a/spec/support/update_invalid_issuable.rb b/spec/support/update_invalid_issuable.rb new file mode 100644 index 00000000000..f984ac7bfa7 --- /dev/null +++ b/spec/support/update_invalid_issuable.rb @@ -0,0 +1,57 @@ +shared_examples 'update invalid issuable' do |klass| + let(:params) do + { + namespace_id: project.namespace.path, + project_id: project.path, + id: issuable.iid + } + end + + let(:issuable) do + klass == Issue ? issue : merge_request + end + + before do + if klass == Issue + params.merge!(issue: { title: "any" }) + else + params.merge!(merge_request: { title: "any" }) + end + end + + context 'when updating causes conflicts' do + before do + allow_any_instance_of(issuable.class).to receive(:save). + and_raise(ActiveRecord::StaleObjectError.new(issuable, :save)) + end + + it 'renders edit when format is html' do + put :update, params + + expect(response).to render_template(:edit) + expect(assigns[:conflict]).to be_truthy + end + + it 'renders json error message when format is json' do + params.merge!(format: "json") + + put :update, params + + expect(response.status).to eq(409) + expect(JSON.parse(response.body)).to have_key('errors') + end + end + + context 'when updating an invalid issuable' do + before do + key = klass == Issue ? :issue : :merge_request + params[key][:title] = "" + end + + it 'renders edit when merge request is invalid' do + put :update, params + + expect(response).to render_template(:edit) + end + end +end diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb index 6098be5cd45..ea714fb08f0 100644 --- a/spec/uploaders/attachment_uploader_spec.rb +++ b/spec/uploaders/attachment_uploader_spec.rb @@ -1,18 +1,17 @@ require 'spec_helper' describe AttachmentUploader do - let(:issue) { build(:issue) } - subject { described_class.new(issue) } + let(:uploader) { described_class.new(build_stubbed(:user)) } describe '#move_to_cache' do it 'is true' do - expect(subject.move_to_cache).to eq(true) + expect(uploader.move_to_cache).to eq(true) end end describe '#move_to_store' do it 'is true' do - expect(subject.move_to_store).to eq(true) + expect(uploader.move_to_store).to eq(true) end end end diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb index 76f5a4b42ed..c4d558805ab 100644 --- a/spec/uploaders/avatar_uploader_spec.rb +++ b/spec/uploaders/avatar_uploader_spec.rb @@ -1,18 +1,17 @@ require 'spec_helper' describe AvatarUploader do - let(:user) { build(:user) } - subject { described_class.new(user) } + let(:uploader) { described_class.new(build_stubbed(:user)) } describe '#move_to_cache' do it 'is false' do - expect(subject.move_to_cache).to eq(false) + expect(uploader.move_to_cache).to eq(false) end end describe '#move_to_store' do it 'is false' do - expect(subject.move_to_store).to eq(false) + expect(uploader.move_to_store).to eq(false) end end end diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index 6a712e33c96..b0f5be55c33 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -1,57 +1,35 @@ require 'spec_helper' describe FileUploader do - let(:project) { create(:project) } + let(:uploader) { described_class.new(build_stubbed(:project)) } - before do - @previous_enable_processing = FileUploader.enable_processing - FileUploader.enable_processing = false - @uploader = FileUploader.new(project) - end - - after do - FileUploader.enable_processing = @previous_enable_processing - @uploader.remove! - end + describe 'initialize' do + it 'generates a secret if none is provided' do + expect(SecureRandom).to receive(:hex).and_return('secret') - describe '#image_or_video?' do - context 'given an image file' do - before do - @uploader.store!(fixture_file_upload(Rails.root.join('spec', 'fixtures', 'rails_sample.jpg'))) - end + uploader = described_class.new(double) - it 'detects an image based on file extension' do - expect(@uploader.image_or_video?).to be true - end + expect(uploader.secret).to eq 'secret' end - context 'given an video file' do - before do - video_file = fixture_file_upload(Rails.root.join('spec', 'fixtures', 'video_sample.mp4')) - @uploader.store!(video_file) - end - - it 'detects a video based on file extension' do - expect(@uploader.image_or_video?).to be true - end - end + it 'accepts a secret parameter' do + expect(SecureRandom).not_to receive(:hex) - it 'does not return image_or_video? for other types' do - @uploader.store!(fixture_file_upload(Rails.root.join('spec', 'fixtures', 'doc_sample.txt'))) + uploader = described_class.new(double, 'secret') - expect(@uploader.image_or_video?).to be false + expect(uploader.secret).to eq 'secret' end end describe '#move_to_cache' do it 'is true' do - expect(@uploader.move_to_cache).to eq(true) + expect(uploader.move_to_cache).to eq(true) end end describe '#move_to_store' do it 'is true' do - expect(@uploader.move_to_store).to eq(true) + expect(uploader.move_to_store).to eq(true) end end end diff --git a/spec/uploaders/uploader_helper_spec.rb b/spec/uploaders/uploader_helper_spec.rb new file mode 100644 index 00000000000..e9efd13b9aa --- /dev/null +++ b/spec/uploaders/uploader_helper_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe UploaderHelper do + class ExampleUploader < CarrierWave::Uploader::Base + include UploaderHelper + + storage :file + end + + def upload_fixture(filename) + fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) + end + + describe '#image_or_video?' do + let(:uploader) { ExampleUploader.new } + + it 'returns true for an image file' do + uploader.store!(upload_fixture('dk.png')) + + expect(uploader).to be_image_or_video + end + + it 'it returns true for a video file' do + uploader.store!(upload_fixture('video_sample.mp4')) + + expect(uploader).to be_image_or_video + end + + it 'returns false for other extensions' do + uploader.store!(upload_fixture('doc_sample.txt')) + + expect(uploader).not_to be_image_or_video + end + end +end diff --git a/yarn.lock b/yarn.lock index 1eaa04e21c1..cb4ef36119f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4410,9 +4410,9 @@ vue-resource@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/vue-resource/-/vue-resource-0.9.3.tgz#ab46e1c44ea219142dcc28ae4043b3b04c80959d" -vue@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.0.3.tgz#3f7698f83d6ad1f0e35955447901672876c63fde" +vue@^2.1.10: + version "2.1.10" + resolved "https://registry.yarnpkg.com/vue/-/vue-2.1.10.tgz#c9235ca48c7925137be5807832ac4e3ac180427b" watchpack@^1.2.0: version "1.2.1" |