diff options
75 files changed, 1458 insertions, 257 deletions
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 824febe3fd3..5e449170cd3 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -4,6 +4,7 @@ (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + var AUTO_SCROLL_OFFSET = 75; this.Build = (function() { Build.interval = null; @@ -19,6 +20,17 @@ this.buildStage = options.buildStage; this.updateDropdown = bind(this.updateDropdown, this); this.$document = $(document); + this.$body = $('body'); + this.$buildTrace = $('#build-trace'); + this.$autoScrollContainer = $('.autoscroll-container'); + this.$autoScrollStatus = $('#autoscroll-status'); + this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); + this.$upBuildTrace = $('#up-build-trace'); + this.$downBuildTrace = $('#down-build-trace'); + this.$scrollTopBtn = $('#scroll-top'); + this.$scrollBottomBtn = $('#scroll-bottom'); + this.$buildRefreshAnimation = $('.js-build-refresh'); + clearInterval(Build.interval); // Init breakpoint checker this.bp = Breakpoints.get(); @@ -32,6 +44,7 @@ this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); + this.$document.on('scroll', this.initScrollMonitor.bind(this)); $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this)); $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace); this.updateArtifactRemoveDate(); @@ -40,18 +53,6 @@ this.initScrollButtonAffix(); } if (this.buildStatus === "running" || this.buildStatus === "pending") { - // Bind autoscroll button to follow build output - $('#autoscroll-button').on('click', function() { - var state; - state = $(this).data("state"); - if ("enabled" === state) { - $(this).data("state", "disabled"); - return $(this).text("Enable autoscroll"); - } else { - $(this).data("state", "enabled"); - return $(this).text("Disable autoscroll"); - } - }); 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 @@ -91,9 +92,10 @@ success: function(buildData) { $('.js-build-output').html(buildData.trace_html); if (removeRefreshStatuses.indexOf(buildData.status) >= 0) { - return $('.js-build-refresh').remove(); + this.initScrollMonitor(); + return this.$buildRefreshAnimation.remove(); } - } + }.bind(this) }); }; @@ -122,22 +124,95 @@ }; Build.prototype.checkAutoscroll = function() { - if ("enabled" === $("#autoscroll-button").data("state")) { - return $("html,body").scrollTop($("#build-trace").height()); + if (this.$autoScrollStatus.data("state") === "enabled") { + return $("html,body").scrollTop(this.$buildTrace.height()); + } + + // Handle a situation where user started new build + // but never scrolled a page + if (!this.$scrollTopBtn.is(':visible') && + !this.$scrollBottomBtn.is(':visible') && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + this.$scrollBottomBtn.show(); } }; Build.prototype.initScrollButtonAffix = function() { - var $body, $buildTrace; - $body = $('body'); - $buildTrace = $('#build-trace'); - return this.$buildScroll.affix({ - offset: { - bottom: function() { - return $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top); - } + // Hide everything initially + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.hide(); + this.$autoScrollContainer.hide(); + } + + // Page scroll listener to detect if user has scrolling page + // and handle following cases + // 1) User is at Top of Build Log; + // - Hide Top Arrow button + // - Show Bottom Arrow button + // - Disable Autoscroll and hide indicator (when build is running) + // 2) User is at Bottom of Build Log; + // - Show Top Arrow button + // - Hide Bottom Arrow button + // - Enable Autoscroll and show indicator (when build is running) + // 3) User is somewhere in middle of Build Log; + // - Show Top Arrow button + // - Show Bottom Arrow button + // - Disable Autoscroll and hide indicator (when build is running) + Build.prototype.initScrollMonitor = function() { + if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // User is somewhere in middle of Build Log + + this.$scrollTopBtn.show(); + + if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed + this.$scrollBottomBtn.show(); + } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { + this.$scrollBottomBtn.show(); + } else { + this.$scrollBottomBtn.hide(); } - }); + + // Hide Autoscroll Status Indicator + if (this.$scrollBottomBtn.is(':visible')) { + this.$autoScrollContainer.hide(); + this.$autoScrollStatusText.removeClass('animate'); + } else { + this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollStatusText.addClass('animate'); + } + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // User is at Top of Build Log + + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.show(); + + this.$autoScrollContainer.hide(); + this.$autoScrollStatusText.removeClass('animate'); + } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) || + (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { + // User is at Bottom of Build Log + + this.$scrollTopBtn.show(); + this.$scrollBottomBtn.hide(); + + // Show and Reposition Autoscroll Status Indicator + this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollStatusText.addClass('animate'); + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // Build Log height is small + + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.hide(); + + // Hide Autoscroll Status Indicator + this.$autoScrollContainer.hide(); + this.$autoScrollStatusText.removeClass('animate'); + } + + if (this.buildStatus === "running" || this.buildStatus === "pending") { + // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. + this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled'); + } }; Build.prototype.shouldHideSidebarForViewport = function() { diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 17d03c87bf5..cbd8ac4eddd 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -112,7 +112,6 @@ return value.path != null ? this.Emoji.template : this.Loading.template; }.bind(this), insertTpl: ':${name}:', - startWithSpace: false, skipSpecialCharacterTest: true, data: this.defaultLoadingData, callbacks: { @@ -129,7 +128,6 @@ }.bind(this), insertTpl: '${atwho-at}${username}', searchKey: 'search', - startWithSpace: false, alwaysHighlightFirst: true, skipSpecialCharacterTest: true, data: this.defaultLoadingData, @@ -172,7 +170,6 @@ }.bind(this), data: this.defaultLoadingData, insertTpl: '${atwho-at}${id}', - startWithSpace: false, callbacks: { sorter: this.DefaultOptions.sorter, filter: this.DefaultOptions.filter, @@ -200,7 +197,6 @@ displayTpl: function(value) { return value.title != null ? this.Milestones.template : this.Loading.template; }.bind(this), - startWithSpace: false, data: this.defaultLoadingData, callbacks: { matcher: this.DefaultOptions.matcher, @@ -225,7 +221,6 @@ at: '!', alias: 'mergerequests', searchKey: 'search', - startWithSpace: false, displayTpl: function(value) { return value.title != null ? this.Issues.template : this.Loading.template; }.bind(this), @@ -259,7 +254,6 @@ return this.isLoading(value) ? this.Loading.template : this.Labels.template; }.bind(this), insertTpl: '${atwho-at}${title}', - startWithSpace: false, callbacks: { matcher: this.DefaultOptions.matcher, sorter: this.DefaultOptions.sorter, @@ -379,14 +373,7 @@ togglePreventSelection(isPrevented = !!this.setting.tabSelectsMatch) { this.setting.tabSelectsMatch = !isPrevented; this.setting.spaceSelectsMatch = !isPrevented; - const eventListenerAction = `${isPrevented ? 'add' : 'remove'}EventListener`; - this.$inputor[0][eventListenerAction]('keydown', gl.GfmAutoComplete.preventSpaceTabEnter); }, - preventSpaceTabEnter(e) { - const key = e.which || e.keyCode; - const preventables = [9, 13, 32]; - if (preventables.indexOf(key) > -1) e.preventDefault(); - } }; }).call(this); diff --git a/app/assets/javascripts/issuable.js.es6 b/app/assets/javascripts/issuable.js.es6 index 1c10a7445bb..9c3c96c20ed 100644 --- a/app/assets/javascripts/issuable.js.es6 +++ b/app/assets/javascripts/issuable.js.es6 @@ -1,13 +1,13 @@ -/* eslint-disable func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, prefer-const, padded-blocks, wrap-iife, max-len */ +/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, prefer-const, padded-blocks, wrap-iife, max-len */ /* global Issuable */ /* global Turbolinks */ -(function() { +((global) => { var issuable_created; issuable_created = false; - this.Issuable = { + global.Issuable = { init: function() { Issuable.initTemplates(); Issuable.initSearch(); @@ -111,7 +111,11 @@ filterResults: (function(_this) { return function(form) { var formAction, formData, issuesUrl; - formData = form.serialize(); + formData = form.serializeArray(); + formData = formData.filter(function(data) { + return data.value !== ''; + }); + formData = $.param(formData); formAction = form.attr('action'); issuesUrl = formAction; issuesUrl += "" + (formAction.indexOf('?') < 0 ? '?' : '&'); @@ -184,4 +188,4 @@ } }; -}).call(this); +})(window); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8fa80502d92..0a0e73e0ccc 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -93,6 +93,19 @@ } }; + // Check if element scrolled into viewport from above or below + // Courtesy http://stackoverflow.com/a/7557433/414749 + w.gl.utils.isInViewport = function(el) { + var rect = el.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ); + }; + gl.utils.getPagePath = function() { return $('body').data('page').split(':')[0]; }; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 66f7e7f97c8..f9e8d297c05 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -1,3 +1,34 @@ +@keyframes fade-out-status { + 0%, 50% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes blinking-dots { + 0% { + background-color: rgba($white-light, 1); + box-shadow: 12px 0 0 0 rgba($white-light,0.2), + 24px 0 0 0 rgba($white-light,0.2); + } + + 25% { + background-color: rgba($white-light, 0.4); + box-shadow: 12px 0 0 0 rgba($white-light,2), + 24px 0 0 0 rgba($white-light,0.2); + } + + 75% { + background-color: rgba($white-light, 0.4); + box-shadow: 12px 0 0 0 rgba($white-light,0.2), + 24px 0 0 0 rgba($white-light,1); + } + + 100% { + background-color: rgba($white-light, 1); + box-shadow: 12px 0 0 0 rgba($white-light,0.2), + 24px 0 0 0 rgba($white-light,0.2); + } +} + .build-page { pre.trace { background: $builds-trace-bg; @@ -14,47 +45,101 @@ } } - .scroll-controls { - .scroll-step { - width: 31px; - margin: 0 0 0 auto; + .environment-information { + background-color: $gray-light; + border: 1px solid $border-color; + padding: 12px $gl-padding; + border-radius: $border-radius-default; + + svg { + position: relative; + top: 1px; + margin-right: 5px; } + } +} + +.scroll-controls { + height: 100%; + + .scroll-step { + width: 31px; + margin: 0 0 0 auto; + } + + .scroll-link, + .autoscroll-container { + right: 25px; + z-index: 1; + } + + .scroll-link { + position: fixed; + display: block; + margin-bottom: 10px; - &.affix-bottom { - position: absolute; - right: 25px; + &.scroll-top .gitlab-icon-scroll-up-hover, + &.scroll-top:hover .gitlab-icon-scroll-up, + &.scroll-bottom .gitlab-icon-scroll-down-hover, + &.scroll-bottom:hover .gitlab-icon-scroll-down { + display: none; } - &.affix { - right: 25px; - bottom: 15px; - z-index: 1; + &.scroll-top:hover .gitlab-icon-scroll-up-hover, + &.scroll-bottom:hover .gitlab-icon-scroll-down-hover { + display: inline-block; } - &.sidebar-expanded { - right: #{$gutter_width + ($gl-padding * 2)}; + &.scroll-top { + top: 110px; } - a { - display: block; - margin-bottom: 10px; + &.scroll-bottom { + bottom: -2px; } } - .environment-information { - background-color: $gray-light; - border: 1px solid $border-color; - padding: 12px $gl-padding; - border-radius: $border-radius-default; + .autoscroll-container { + position: absolute; + } - svg { - position: relative; - top: 1px; - margin-right: 5px; + &.sidebar-expanded { + + .scroll-link, + .autoscroll-container { + right: ($gutter_width + ($gl-padding * 2)); } } } +.status-message { + display: inline-block; + color: $white-light; + + .status-icon { + display: inline-block; + width: 16px; + height: 33px; + } + + .status-text { + float: left; + opacity: 0; + margin-right: 10px; + font-weight: normal; + line-height: 1.8; + transition: opacity 1s ease-out; + + &.animate { + animation: fade-out-status 2s ease; + } + } + + &:hover .status-text { + opacity: 1; + } +} + .build-header { position: relative; padding: 0; @@ -109,6 +194,15 @@ .bash { display: block; } + + .build-loader-animation { + position: relative; + width: 6px; + height: 6px; + margin: auto auto 12px 2px; + border-radius: 50%; + animation: blinking-dots 1s linear infinite; + } } .right-sidebar.build-sidebar { @@ -248,6 +342,12 @@ } } +.build-sidebar { + .container-fluid.container-limited { + max-width: 100%; + } +} + .build-detail-row { margin-bottom: 5px; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index e779e65eca3..394980704ae 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -21,6 +21,14 @@ display: inline-block; float: left; + .btn-success.dropdown-toggle .fa { + color: inherit; + } + + .btn-success.dropdown-toggle:disabled { + background-color: $gl-success; + } + .accept_merge_request { &.ci-pending, &.ci-running { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 106c5d4d390..6ac4ec6ea0d 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -43,7 +43,7 @@ ul.notes { } .system-note-message { - display: inline-block; + display: inline; &::first-letter { text-transform: lowercase; @@ -55,7 +55,7 @@ ul.notes { } p { - display: inline-block; + display: inline; margin: 0; &::first-letter { @@ -151,6 +151,10 @@ ul.notes { } } } + + .note-headline-light { + display: inline; + } } .discussion-body { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 566de8a4eba..f6164c8907e 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -80,6 +80,10 @@ td { padding: 10px 8px; } + + .commit-link { + padding: 9px 8px 10px; + } } tbody { @@ -193,7 +197,7 @@ width: 8px; position: absolute; right: -7px; - bottom: 9px; + bottom: 10px; border-bottom: 2px solid $border-color; } } @@ -499,15 +503,10 @@ > .ci-action-icon-container { position: absolute; - right: 4px; + right: 5px; top: 5px; } - .ci-status-icon { - position: relative; - top: 1px; - } - .ci-status-icon svg { height: 20px; width: 20px; @@ -614,6 +613,10 @@ a { display: inline-block; + } + + .build-content { + width: 138px; &:hover { background-color: $stage-hover-bg; @@ -623,15 +626,24 @@ ul { max-height: 245px; overflow: auto; - margin: 5px 0; + margin: 3px 0; li { padding-top: 2px; - margin: 0 5px; + margin: 4px 7px; + padding: 0 3px; padding-left: 0; padding-bottom: 0; - margin-bottom: 0; - line-height: 1.2; + line-height: 0; + + .ci-action-icon-container:hover { + background-color: transparent; + } + + .ci-status-icon { + position: relative; + top: 2px; + } } } } @@ -680,11 +692,15 @@ .dropdown-build { color: $gl-text-color-light; + .build-content { + padding: 3px 7px 6px; + } + .ci-action-icon-container { padding: 0; font-size: 11px; float: right; - margin-top: 4px; + margin-top: 3px; display: inline-block; position: relative; @@ -694,16 +710,10 @@ } } - &:hover { - background-color: $stage-hover-bg; - border-radius: 3px; - color: $gl-text-color; - } - .ci-action-icon-container { i { - width: 25px; - height: 25px; + width: 24px; + height: 24px; &::before { top: 1px; @@ -740,6 +750,10 @@ margin: 0; } + .dropdown-build .build-content { + padding: 3px 7px 7px; + } + .builds-dropdown-loading { margin: 10px auto; width: 18px; @@ -788,19 +802,25 @@ .mini-pipeline-graph-icon-container .ci-status-icon { display: inline-block; border: 1px solid; - border-radius: 20px; + border-radius: 22px; margin-right: 1px; - width: 20px; - height: 20px; + width: 22px; + height: 22px; position: relative; z-index: 2; transition: all 0.2s cubic-bezier(0.25, 0, 1, 1); svg { top: -1px; + left: -1px; } } +.stage-cell .mini-pipeline-graph-icon-container .ci-status-icon svg { + width: 22px; + height: 22px; +} + .builds-dropdown { &:focus { outline: none; @@ -851,7 +871,7 @@ .mini-pipeline-graph-icon-container { .ci-status-icon:hover, .ci-status-icon:focus { - width: 28px; + width: 32px; padding: 0 8px 0 0; + .dropdown-caret { @@ -863,7 +883,7 @@ font-size: 11px; position: relative; top: 3px; - left: -11px; + left: -14px; margin-right: -6px; display: none; z-index: 2; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index e16a553bcda..d6aa4c4c032 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -880,3 +880,23 @@ pre.light-well { width: 30%; } } + +.services-installation-info .row { + margin-bottom: 10px; +} + +.service-installation { + padding: 32px; + margin: 32px; + border-radius: 3px; + background-color: $white-light; + + h3 { + margin-top: 0; + } + + hr { + margin: 32px 0; + border-color: $border-color; + } +} diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index a810ed32327..4acd17360c1 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -1,6 +1,6 @@ .container-fluid { .ci-status { - padding: 2px 7px; + padding: 2px 7px 4px; margin-right: 10px; border: 1px solid $gray-darker; white-space: nowrap; diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index b83c3a872cf..efe9c001bcf 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -82,6 +82,8 @@ class GroupsController < Groups::ApplicationController if Groups::UpdateService.new(@group, current_user, group_params).execute redirect_to edit_group_path(@group), notice: "Group '#{@group.name}' was successfully updated." else + @group.reset_path! + render action: "edit" end end diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb new file mode 100644 index 00000000000..d87dff2a80e --- /dev/null +++ b/app/controllers/projects/mattermosts_controller.rb @@ -0,0 +1,43 @@ +class Projects::MattermostsController < Projects::ApplicationController + include TriggersHelper + include ActionView::Helpers::AssetUrlHelper + + layout 'project_settings' + + before_action :authorize_admin_project! + before_action :service + before_action :teams, only: [:new] + + def new + end + + def create + result, message = @service.configure(current_user, configure_params) + + if result + flash[:notice] = 'This service is now configured' + redirect_to edit_namespace_project_service_path( + @project.namespace, @project, service) + else + flash[:alert] = message || 'Failed to configure service' + redirect_to new_namespace_project_mattermost_path( + @project.namespace, @project) + end + end + + private + + def configure_params + params.require(:mattermost).permit(:trigger, :team_id).merge( + url: service_trigger_url(@service), + icon_url: asset_url('gitlab_logo.png')) + end + + def teams + @teams ||= @service.list_teams(current_user) + end + + def service + @service ||= @project.find_or_initialize_service('mattermost_slash_commands') + end +end diff --git a/app/helpers/mattermost_helper.rb b/app/helpers/mattermost_helper.rb new file mode 100644 index 00000000000..49ac12db832 --- /dev/null +++ b/app/helpers/mattermost_helper.rb @@ -0,0 +1,9 @@ +module MattermostHelper + def mattermost_teams_options(teams) + teams_options = teams.map do |id, options| + [options['display_name'] || options['name'], id] + end + + teams_options.compact.unshift(['Select team...', '0']) + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index fd42f2328d8..b52f08c7081 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -98,7 +98,7 @@ class Namespace < ActiveRecord::Base def move_dir if any_project_has_container_registry_tags? - raise Exception.new('Namespace cannot be moved, because at least one project has tags in container registry') + raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry') end # Move the namespace directory in all storages paths used by member projects @@ -111,7 +111,7 @@ class Namespace < ActiveRecord::Base # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs - raise Exception.new('namespace directory cannot be moved') + raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') end end diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb index 10740275669..6c78c0af71c 100644 --- a/app/models/project_services/mattermost_slash_commands_service.rb +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -18,4 +18,34 @@ class MattermostSlashCommandsService < ChatSlashCommandsService def to_param 'mattermost_slash_commands' end + + def configure(user, params) + token = Mattermost::Command.new(user). + create(command(params)) + + update(active: true, token: token) if token + rescue Mattermost::Error => e + [false, e.message] + end + + def list_teams(user) + Mattermost::Team.new(user).all + rescue Mattermost::Error => e + [[], e.message] + end + + private + + def command(params) + pretty_project_name = project.name_with_namespace + + params.merge( + auto_complete: true, + auto_complete_desc: "Perform common operations on: #{pretty_project_name}", + auto_complete_hint: '[help]', + description: "Perform common operations on: #{pretty_project_name}", + display_name: "GitLab / #{pretty_project_name}", + method: 'P', + user_name: 'GitLab') + end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index fff2273f402..4e878ec556a 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -14,7 +14,13 @@ module Groups group.assign_attributes(params) - group.save + begin + group.save + rescue Gitlab::UpdatePathError => e + group.errors.add(:base, e.message) + + false + end end end end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb index 927c67b65b0..79b2c99fd70 100644 --- a/app/validators/project_path_validator.rb +++ b/app/validators/project_path_validator.rb @@ -14,7 +14,8 @@ class ProjectPathValidator < ActiveModel::EachValidator # without tree as reserved name routing can match 'group/project' as group name, # 'tree' as project name and 'deploy_keys' as route. # - RESERVED = (NamespaceValidator::RESERVED + + RESERVED = (NamespaceValidator::RESERVED - + %w[dashboard help ci admin search] + %w[tree commits wikis new edit create update logs_tree preview blob blame raw files create_dir find_file]).freeze diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index cdeb81372ee..c69c53b656f 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -56,17 +56,22 @@ - else #js-build-scroll.scroll-controls .scroll-step - = link_to '#build-trace', class: 'btn' do - %i.fa.fa-angle-up - = link_to '#down-build-trace', class: 'btn' do - %i.fa.fa-angle-down + %a{ href: '#up-build-trace', id: 'scroll-top', class: 'scroll-link scroll-top', title: 'Scroll to top' } + = custom_icon('scroll_up') + = custom_icon('scroll_up_hover_active') + %a{ href: '#down-build-trace', id: 'scroll-bottom', class: 'scroll-link scroll-bottom', title: 'Scroll to bottom' } + = custom_icon('scroll_down') + = custom_icon('scroll_down_hover_active') - if @build.active? .autoscroll-container - %button.btn.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} - Enable autoscroll + %span.status-message#autoscroll-status{ data: { state: 'disabled' } } + %span.status-text Autoscroll active + %i.status-icon + = custom_icon('scroll_down_hover_active') + #up-build-trace %pre.build-trace#build-trace %code.bash.js-build-output - = icon("refresh spin", class: "js-build-refresh") + .build-loader-animation.js-build-refresh #down-build-trace diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml new file mode 100644 index 00000000000..605c7f61dee --- /dev/null +++ b/app/views/projects/mattermosts/_no_teams.html.haml @@ -0,0 +1,12 @@ +%p + You aren’t a member of any team on the Mattermost instance at + %strong= Gitlab.config.mattermost.host +%p + To install this service, + = link_to "#{Gitlab.config.mattermost.host}/select_team", target: '__blank' do + join a team + = icon('external-link') + and try again. +%hr +.clearfix + = link_to 'Go back', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg pull-right' diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml new file mode 100644 index 00000000000..7980f7c9a72 --- /dev/null +++ b/app/views/projects/mattermosts/_team_selection.html.haml @@ -0,0 +1,44 @@ +%p + This service will be installed on the Mattermost instance at + %strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host +%hr += form_for(:mattermost, method: :post, url: namespace_project_mattermost_path(@project.namespace, @project)) do |f| + %h4 Team + %p + = @teams.one? ? 'The team' : 'Select the team' + where the slash commands will be used in + - selected_id = @teams.keys.first if @teams.one? + = f.select(:team_id, mattermost_teams_options(@teams), {}, { class: 'form-control', selected: "#{selected_id}", disabled: @teams.one? }) + .help-block + - if @teams.one? + This is the only team where you are an administrator. + - else + The list shows teams where you are administrator + To create a team, ask your Mattermost system administrator. + To create a team, + = link_to "#{Gitlab.config.mattermost.host}/create_team" do + use Mattermost's interface + = icon('external-link') + %hr + %h4 Command trigger word + %p Choose the word that will trigger commands + = f.text_field(:trigger, value: @project.path, class: 'form-control') + .help-block + %p + Trigger word must be unique, and can't begin with a slash or contain any spaces. + Use the word that works best for your team. + %p + Suggestions: + %code= 'gitlab' + %code= @project.path # Path contains no spaces, but dashes + %code= @project.path_with_namespace + %p + Reserved: + = link_to 'https://docs.mattermost.com/help/messaging/executing-commands.html#built-in-commands', target: '__blank' do + see list of built-in slash commands + = icon('external-link') + %hr + .clearfix + .pull-right + = link_to 'Cancel', edit_namespace_project_service_path(@project.namespace, @project, @service), class: 'btn btn-lg' + = f.submit 'Install', class: 'btn btn-save btn-lg' diff --git a/app/views/projects/mattermosts/new.html.haml b/app/views/projects/mattermosts/new.html.haml new file mode 100644 index 00000000000..96b1d2aee61 --- /dev/null +++ b/app/views/projects/mattermosts/new.html.haml @@ -0,0 +1,8 @@ +.service-installation + .inline.pull-right + = custom_icon('mattermost_logo', size: 48) + %h3 Install Mattermost Command + - if @teams.empty? + = render 'no_teams' + - else + = render 'team_selection' diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 9ab7971b56c..5bc417d1760 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -17,7 +17,7 @@ - # TODO, remove in later versions when services like Jenkins will set CI status via Commit status API .mr-widget-heading - %w[success skipped canceled failed running pending].each do |status| - .ci_widget{class: "ci-#{status}", style: "display:none"} + .ci_widget{class: "ci-#{status} ci-status-icon-#{status}", style: "display:none"} = ci_icon_for_status(status) %span CI build diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index db51c4f8a4e..fc338dcf887 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -8,7 +8,6 @@ .col-lg-9 = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'form-horizontal' }) do |form| = render 'shared/service_settings', form: form, subject: @service - .footer-block.row-content-block = form.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml new file mode 100644 index 00000000000..8ca4c51a064 --- /dev/null +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -0,0 +1,91 @@ +- run_actions_text = "Perform common operations on this project: #{@project.name_with_namespace}" + +To setup this service: +%ul.list-unstyled + %li + 1. + = link_to 'Enable custom slash commands', 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands' + on your Mattermost installation + %li + 2. + = link_to 'Add a slash command', 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command' + in Mattermost with these options: + +%hr + +.help-form + .form-group + = label_tag :display_name, 'Display name', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#display_name') + + .form-group + = label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#description') + + .form-group + = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.text-block + %p Fill in the word that works best for your team. + %p + Suggestions: + %code= 'gitlab' + %code= @project.path # Path contains no spaces, but dashes + %code= @project.path_with_namespace + + .form-group + = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#request_url') + + .form-group + = label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.text-block POST + + .form-group + = label_tag :response_username, 'Response username', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#response_username') + + .form-group + = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#response_icon') + + .form-group + = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.text-block Yes + + .form-group + = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#autocomplete_hint') + + .form-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label' + .col-sm-10.col-xs-12.input-group + = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' + .input-group-btn + = clipboard_button(clipboard_target: '#autocomplete_description') + +%hr + +%ul.list-unstyled + %li + 3. After adding the slash command, paste the + + %strong token + into the field below diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index 01a77a952d1..63b797cd391 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -1,4 +1,4 @@ -- run_actions_text = "Perform common operations on this project: #{@project.name_with_namespace}" +- enabled = Gitlab.config.mattermost.enabled .well This service allows GitLab users to perform common operations on this @@ -7,93 +7,9 @@ See list of available commands in Mattermost after setting up this service, by entering %code /<command_trigger_word> help - %br - %br - To setup this service: - %ul.list-unstyled - %li - 1. - = link_to 'Enable custom slash commands', 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands' - on your Mattermost installation - %li - 2. - = link_to 'Add a slash command', 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command' - in Mattermost with these options: - - %hr - - .help-form - .form-group - = label_tag :display_name, 'Display name', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#display_name') - - .form-group - = label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#description') - - .form-group - = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.text-block - %p Fill in the word that works best for your team. - %p - Suggestions: - %code= 'gitlab' - %code= @project.path # Path contains no spaces, but dashes - %code= @project.path_with_namespace - - .form-group - = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#request_url') - - .form-group - = label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.text-block POST - - .form-group - = label_tag :response_username, 'Response username', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#response_username') - - .form-group - = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#response_icon') - - .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.text-block Yes - - .form-group - = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_hint') - - .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label' - .col-sm-10.col-xs-12.input-group - = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' - .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_description') - %hr + - unless enabled + = render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service - %ul.list-unstyled - %li - 3. After adding the slash command, paste the - %strong token - into the field below +- if enabled + = render 'projects/services/mattermost_slash_commands/installation_info', subject: @service diff --git a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml new file mode 100644 index 00000000000..c929eee3bb9 --- /dev/null +++ b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml @@ -0,0 +1,7 @@ +.services-installation-info + - unless @service.activated? + .row + .col-sm-9.col-sm-offset-3 + = link_to new_namespace_project_mattermost_path(@project.namespace, @project), class: 'btn btn-lg' do + = custom_icon('mattermost_logo', size: 15) + = 'Add to Mattermost' diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 07d4927b6c9..e2033654018 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -10,10 +10,10 @@ .text-content - if has_button && current_user %h4 - The Issue Tracker is a good place to add things that need to be improved or solved in a project! + The Issue Tracker is the place to add things that need to be improved or solved in a project %p - An issue can be a bug, a todo or a feature request that needs to be discussed in a project. - Besides, issues are searchable and filterable. + Issues can be bugs, tasks or ideas to be discussed. + Also, issues are searchable and filterable. - if project_select_button = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue' - else diff --git a/app/views/shared/icons/_mattermost_logo.svg.erb b/app/views/shared/icons/_mattermost_logo.svg.erb new file mode 100644 index 00000000000..83fbd1a407d --- /dev/null +++ b/app/views/shared/icons/_mattermost_logo.svg.erb @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="<%= size %>" height="<%= size %>" version="1" viewBox="0 0 501 501"><path d="M236 .7C137.7 7.5 54 68.2 18.2 158.5c-32 81-19.6 172.8 33 242.5 39.8 53 97.2 87 164.3 97 16.5 2.7 48 3.2 63.5 1.2 48.7-6.3 92.2-24.6 129-54.2 13-10.5 33-31.2 42.2-43.7 26.4-35.5 42.8-75.8 49-120.3 1.6-12.3 1.6-48.7 0-61-4-28.3-12-54.8-24.2-79.5-12.8-26-26.5-45.3-46.8-65.8C417.8 64 400.2 49 398.4 49c-.6 0-.4 10.5.3 26l1.3 26 7 8.7c19 23.7 32.8 53.5 38.2 83 2.5 14 3 43 1 55.8-4.5 27.8-15.2 54-31 76.5-8.6 12.2-28 31.6-40.2 40.2-24 17-50 27.6-80 33-10 1.8-49 1.8-59 0-43-7.7-78.8-26-107.2-54.8-29.3-29.7-46.5-64-52.4-104.4-2-14-1.5-42 1-55C90 121.4 132 72 192 49.7c8-3 18.4-5.8 29.5-8.2 1.7-.4 34.4-38 35.3-40.6.3-1-10.2-1-20.8-.4z"/><path d="M322.2 24.6c-1.3.8-8.4 9.3-16 18.7-7.4 9.5-22.4 28-33.2 41.2-51 62.2-66 81.6-70.6 91-6 12-8.4 21-9 33-1.2 19.8 5 36 19 50C222 268 230 273 243 277.2c9 3 10.4 3.2 24 3.2 13.8 0 15 0 22.6-3 23.2-9 39-28.4 45-55.7 2-8.2 2-28.7.4-79.7l-2-72c-1-36.8-1.4-41.8-3-44-2-3-4.8-3.6-7.8-1.4z"/></svg> diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg new file mode 100644 index 00000000000..acf22ac9314 --- /dev/null +++ b/app/views/shared/icons/_scroll_down.svg @@ -0,0 +1,3 @@ +<svg width="16" height="33" class="gitlab-icon-scroll-down" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> + <path fill="#ffffff" d="M1.385 5.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15V5.535a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> +</svg> diff --git a/app/views/shared/icons/_scroll_down_hover_active.svg b/app/views/shared/icons/_scroll_down_hover_active.svg new file mode 100644 index 00000000000..262576acf54 --- /dev/null +++ b/app/views/shared/icons/_scroll_down_hover_active.svg @@ -0,0 +1,3 @@ +<svg width="16" height="33" class="gitlab-icon-scroll-down-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> + <path fill="#ffffff" d="M8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> +</svg> diff --git a/app/views/shared/icons/_scroll_up.svg b/app/views/shared/icons/_scroll_up.svg new file mode 100644 index 00000000000..f11288fd59c --- /dev/null +++ b/app/views/shared/icons/_scroll_up.svg @@ -0,0 +1,3 @@ +<svg width="16" height="33" class="gitlab-icon-scroll-up" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> + <path fill="#ffffff" d="M1.385 14.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15v-12.47a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 2.609V6.96a.688.688 0 0 1-.69.688.687.687 0 0 1-.69-.688V2.627L6.155 3.972a.69.69 0 0 1-.976-.976L7.705.47a.685.685 0 0 1 .494-.2.685.685 0 0 1 .493.2l2.526 2.526a.69.69 0 1 1-.976.976L8.88 2.609zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> +</svg> diff --git a/app/views/shared/icons/_scroll_up_hover_active.svg b/app/views/shared/icons/_scroll_up_hover_active.svg new file mode 100644 index 00000000000..4658dbb1bb7 --- /dev/null +++ b/app/views/shared/icons/_scroll_up_hover_active.svg @@ -0,0 +1,3 @@ +<svg width="16" height="33" class="gitlab-icon-scroll-up-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> + <path fill="#ffffff" d="M8.88 2.646l1.362 1.362a.69.69 0 0 0 .976-.976L8.692.507A.685.685 0 0 0 8.2.306a.685.685 0 0 0-.494.2L5.179 3.033a.69.69 0 1 0 .976.976L7.5 2.663v4.179c0 .38.306.688.69.688.381 0 .69-.306.69-.688V2.646zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> +</svg> diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index 40fe53e6a8d..415361f8fbf 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -3,7 +3,7 @@ - show_menu_above = show_menu_above || false - selected_text = selected.try(:title) || params[:milestone_title] - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone") -- if selected.present? +- if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) = dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do diff --git a/changelogs/unreleased/19620-auto-scroll-log.yml b/changelogs/unreleased/19620-auto-scroll-log.yml new file mode 100644 index 00000000000..cf38096683b --- /dev/null +++ b/changelogs/unreleased/19620-auto-scroll-log.yml @@ -0,0 +1,4 @@ +--- +title: Improve Build Log scrolling experience +merge_request: 7895 +author: diff --git a/changelogs/unreleased/22742-filter-protocol-relative-urls.yml b/changelogs/unreleased/22742-filter-protocol-relative-urls.yml new file mode 100644 index 00000000000..b331f5a4eb5 --- /dev/null +++ b/changelogs/unreleased/22742-filter-protocol-relative-urls.yml @@ -0,0 +1,4 @@ +--- +title: 'Filter protocol-relative URLs in ExternalLinkFilter. Fixes issue #22742' +merge_request: 6635 +author: Makoto Scott-Hinkle diff --git a/changelogs/unreleased/25368-fix-left-align-system-note.yml b/changelogs/unreleased/25368-fix-left-align-system-note.yml new file mode 100644 index 00000000000..81fd0888773 --- /dev/null +++ b/changelogs/unreleased/25368-fix-left-align-system-note.yml @@ -0,0 +1,4 @@ +--- +title: Fixes left align issue for long system notes +merge_request: 7982 +author: diff --git a/changelogs/unreleased/25898-ci-icon-color-mr.yml b/changelogs/unreleased/25898-ci-icon-color-mr.yml new file mode 100644 index 00000000000..dd0f93e176f --- /dev/null +++ b/changelogs/unreleased/25898-ci-icon-color-mr.yml @@ -0,0 +1,4 @@ +--- +title: Adds CSS class to status icon on MR widget to prevent non-colored icon +merge_request: 8219 +author: diff --git a/changelogs/unreleased/25905-mr-when-succeeds-dropdown.yml b/changelogs/unreleased/25905-mr-when-succeeds-dropdown.yml new file mode 100644 index 00000000000..39ce0b66768 --- /dev/null +++ b/changelogs/unreleased/25905-mr-when-succeeds-dropdown.yml @@ -0,0 +1,4 @@ +--- +title: Adds background color for disabled state to merge when succeeds dropdown +merge_request: 8222 +author: diff --git a/changelogs/unreleased/25908-fix-grape-after-update.yml b/changelogs/unreleased/25908-fix-grape-after-update.yml new file mode 100644 index 00000000000..026d5592441 --- /dev/null +++ b/changelogs/unreleased/25908-fix-grape-after-update.yml @@ -0,0 +1,4 @@ +--- +title: Use Grape's new Route methods +merge_request: +author: diff --git a/changelogs/unreleased/dz-rename-invalid-groups.yml b/changelogs/unreleased/dz-rename-invalid-groups.yml new file mode 100644 index 00000000000..90af42da01c --- /dev/null +++ b/changelogs/unreleased/dz-rename-invalid-groups.yml @@ -0,0 +1,4 @@ +--- +title: Rename groups with .git in the end of the path +merge_request: 8199 +author: diff --git a/changelogs/unreleased/dz-whitelist-dashboard-project-path.yml b/changelogs/unreleased/dz-whitelist-dashboard-project-path.yml new file mode 100644 index 00000000000..2787a5c57df --- /dev/null +++ b/changelogs/unreleased/dz-whitelist-dashboard-project-path.yml @@ -0,0 +1,4 @@ +--- +title: Allow projects with 'dashboard' as path +merge_request: +author: diff --git a/changelogs/unreleased/dz-whitelist-more-project-names.yml b/changelogs/unreleased/dz-whitelist-more-project-names.yml new file mode 100644 index 00000000000..4a3f1511a0b --- /dev/null +++ b/changelogs/unreleased/dz-whitelist-more-project-names.yml @@ -0,0 +1,4 @@ +--- +title: 'Whitelist next project names: help, ci, admin, search' +merge_request: 8227 +author: diff --git a/changelogs/unreleased/fix-copy-issues-empty-state.yml b/changelogs/unreleased/fix-copy-issues-empty-state.yml new file mode 100644 index 00000000000..a87b7612217 --- /dev/null +++ b/changelogs/unreleased/fix-copy-issues-empty-state.yml @@ -0,0 +1,4 @@ +--- +title: Improve copy in Issue Tracker empty state +merge_request: 8202 +author: diff --git a/changelogs/unreleased/fix-group-path-rename-error.yml b/changelogs/unreleased/fix-group-path-rename-error.yml new file mode 100644 index 00000000000..e3d97ae3987 --- /dev/null +++ b/changelogs/unreleased/fix-group-path-rename-error.yml @@ -0,0 +1,4 @@ +--- +title: Fix 500 error renaming group +merge_request: +author: diff --git a/changelogs/unreleased/mattermost-slash-auto-config.yml b/changelogs/unreleased/mattermost-slash-auto-config.yml new file mode 100644 index 00000000000..43014d38769 --- /dev/null +++ b/changelogs/unreleased/mattermost-slash-auto-config.yml @@ -0,0 +1,4 @@ +--- +title: Allow to auto-configure Mattermost +merge_request: 8070 +author: diff --git a/changelogs/unreleased/zj-remove-unused-services.yml b/changelogs/unreleased/zj-remove-unused-services.yml new file mode 100644 index 00000000000..8ede95f5faa --- /dev/null +++ b/changelogs/unreleased/zj-remove-unused-services.yml @@ -0,0 +1,4 @@ +--- +title: Remove unused and void services from the database +merge_request: +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 2d1d48bf9da..42e5f105d46 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -586,4 +586,4 @@ test: admin_group: '' staging: - <<: *base + <<: *base
\ No newline at end of file diff --git a/config/routes/project.rb b/config/routes/project.rb index 335fccb617b..baabd22b840 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -76,6 +76,8 @@ constraints(ProjectUrlConstrainer.new) do end end + resource :mattermost, only: [:new, :create] + resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do member do put :enable diff --git a/db/migrate/20161220141214_remove_dot_git_from_group_names.rb b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb new file mode 100644 index 00000000000..bd0e4b2cc07 --- /dev/null +++ b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb @@ -0,0 +1,82 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveDotGitFromGroupNames < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + include Gitlab::ShellAdapter + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + invalid_groups.each do |group| + path_was = group['path'] + path_was_wildcard = quote_string("#{path_was}/%") + path = quote_string(rename_path(path_was)) + + move_namespace(group['id'], path_was, path) + + execute "UPDATE routes SET path = '#{path}' WHERE source_type = 'Namespace' AND source_id = #{group['id']}" + execute "UPDATE namespaces SET path = '#{path}' WHERE id = #{group['id']}" + + select_all("SELECT id, path FROM routes WHERE path LIKE '#{path_was_wildcard}'").each do |route| + new_path = "#{path}/#{route['path'].split('/').last}" + execute "UPDATE routes SET path = '#{new_path}' WHERE id = #{route['id']}" + end + end + end + + def down + # nothing to do here + end + + private + + def invalid_groups + select_all("SELECT id, path FROM namespaces WHERE type = 'Group' AND path LIKE '%.git'") + end + + def route_exists?(path) + select_all("SELECT id, path FROM routes WHERE path = '#{quote_string(path)}'").present? + end + + # Accepts invalid path like test.git and returns test_git or + # test_git1 if test_git already taken + def rename_path(path) + # To stay closer with original name and reduce risk of duplicates + # we rename suffix instead of removing it + path = path.sub(/\.git\z/, '_git') + + counter = 0 + base = path + + while route_exists?(path) + counter += 1 + path = "#{base}#{counter}" + end + + path + end + + def move_namespace(group_id, path_was, path) + repository_storage_paths = select_all("SELECT distinct(repository_storage) FROM projects WHERE namespace_id = #{group_id}").map do |row| + Gitlab.config.repositories.storages[row['repository_storage']] + end + + # Move the namespace directory in all storages paths used by member projects + repository_storage_paths.each do |repository_storage_path| + # Ensure old directory exists before moving it + gitlab_shell.add_namespace(repository_storage_path, path_was) + + unless gitlab_shell.mv_namespace(repository_storage_path, path_was, path) + Rails.logger.error "Exception moving path #{repository_storage_path} from #{path_was} to #{path}" + + # if we cannot move namespace directory we should rollback + # db changes in order to prevent out of sync between db and fs + raise Exception.new('namespace directory cannot be moved') + end + end + + Gitlab::UploadsTransfer.new.rename_namespace(path_was, path) + end +end diff --git a/db/post_migrate/20161221140236_remove_unneeded_services.rb b/db/post_migrate/20161221140236_remove_unneeded_services.rb new file mode 100644 index 00000000000..a94ccc43a41 --- /dev/null +++ b/db/post_migrate/20161221140236_remove_unneeded_services.rb @@ -0,0 +1,13 @@ +class RemoveUnneededServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + execute("DELETE FROM services WHERE active = false AND properties = '{}';") + end + + def down + # noop + end +end diff --git a/db/schema.rb b/db/schema.rb index 14801b581e6..05b6c807660 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161213172958) do +ActiveRecord::Schema.define(version: 20161221140236) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -854,7 +854,7 @@ ActiveRecord::Schema.define(version: 20161213172958) do t.datetime "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "scopes", default: "--- []\n", null: false + t.string "scopes", default: "--- []\n", null: false end add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb index 2f19b59e725..d67d466bce8 100644 --- a/lib/banzai/filter/external_link_filter.rb +++ b/lib/banzai/filter/external_link_filter.rb @@ -10,7 +10,7 @@ module Banzai node.set_attribute('href', href) end - if href =~ /\Ahttp(s)?:\/\// && external_url?(href) + if href =~ %r{\A(https?:)?//[^/]} && external_url?(href) node.set_attribute('rel', 'nofollow noreferrer') node.set_attribute('target', '_blank') end diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index 01c96a6fe96..91fb0bb317a 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -70,8 +70,8 @@ module Gitlab def tag_endpoint(trans, env) endpoint = env[ENDPOINT_KEY] - path = endpoint_paths_cache[endpoint.route.route_method][endpoint.route.route_path] - trans.action = "Grape##{endpoint.route.route_method} #{path}" + path = endpoint_paths_cache[endpoint.route.request_method][endpoint.route.path] + trans.action = "Grape##{endpoint.route.request_method} #{path}" end private diff --git a/lib/gitlab/middleware/multipart.rb b/lib/gitlab/middleware/multipart.rb index 65713e73a59..dd99f9bb7d7 100644 --- a/lib/gitlab/middleware/multipart.rb +++ b/lib/gitlab/middleware/multipart.rb @@ -42,7 +42,7 @@ module Gitlab key, value = parsed_field.first if value.nil? - value = File.open(tmp_path) + value = open_file(tmp_path) @open_files << value else value = decorate_params_value(value, @request.params[key], tmp_path) @@ -68,7 +68,7 @@ module Gitlab case path_value when nil - value_hash[path_key] = File.open(tmp_path) + value_hash[path_key] = open_file(tmp_path) @open_files << value_hash[path_key] value_hash when Hash @@ -78,6 +78,10 @@ module Gitlab raise "unexpected path value: #{path_value.inspect}" end end + + def open_file(path) + ::UploadedFile.new(path, File.basename(path), 'application/octet-stream') + end end def initialize(app) diff --git a/lib/gitlab/update_path_error.rb b/lib/gitlab/update_path_error.rb new file mode 100644 index 00000000000..ce14cc887d0 --- /dev/null +++ b/lib/gitlab/update_path_error.rb @@ -0,0 +1,3 @@ +module Gitlab + class UpdatePathError < StandardError; end +end diff --git a/lib/mattermost/client.rb b/lib/mattermost/client.rb new file mode 100644 index 00000000000..ec2903b7ec6 --- /dev/null +++ b/lib/mattermost/client.rb @@ -0,0 +1,41 @@ +module Mattermost + class ClientError < Mattermost::Error; end + + class Client + attr_reader :user + + def initialize(user) + @user = user + end + + private + + def with_session(&blk) + Mattermost::Session.new(user).with_session(&blk) + end + + def json_get(path, options = {}) + with_session do |session| + json_response session.get(path, options) + end + end + + def json_post(path, options = {}) + with_session do |session| + json_response session.post(path, options) + end + end + + def json_response(response) + json_response = JSON.parse(response.body) + + unless response.success? + raise Mattermost::ClientError.new(json_response['message'] || 'Undefined error') + end + + json_response + rescue JSON::JSONError + raise Mattermost::ClientError.new('Cannot parse response') + end + end +end diff --git a/lib/mattermost/command.rb b/lib/mattermost/command.rb new file mode 100644 index 00000000000..d1e4bb0eccf --- /dev/null +++ b/lib/mattermost/command.rb @@ -0,0 +1,10 @@ +module Mattermost + class Command < Client + def create(params) + response = json_post("/api/v3/teams/#{params[:team_id]}/commands/create", + body: params.to_json) + + response['token'] + end + end +end diff --git a/lib/mattermost/error.rb b/lib/mattermost/error.rb new file mode 100644 index 00000000000..014df175be0 --- /dev/null +++ b/lib/mattermost/error.rb @@ -0,0 +1,3 @@ +module Mattermost + class Error < StandardError; end +end diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb index fb8d7d97f8a..377cb7b1021 100644 --- a/lib/mattermost/session.rb +++ b/lib/mattermost/session.rb @@ -1,5 +1,12 @@ module Mattermost - class NoSessionError < StandardError; end + class NoSessionError < Mattermost::Error + def message + 'No session could be set up, is Mattermost configured with Single Sign On?' + end + end + + class ConnectionError < Mattermost::Error; end + # This class' prime objective is to obtain a session token on a Mattermost # instance with SSO configured where this GitLab instance is the provider. # @@ -17,6 +24,8 @@ module Mattermost include Doorkeeper::Helpers::Controller include HTTParty + LEASE_TIMEOUT = 60 + base_uri Settings.mattermost.host attr_accessor :current_resource_owner, :token @@ -26,12 +35,16 @@ module Mattermost end def with_session - raise NoSessionError unless create - - begin - yield self - ensure - destroy + with_lease do + raise Mattermost::NoSessionError unless create + + begin + yield self + rescue Errno::ECONNREFUSED + raise Mattermost::NoSessionError + ensure + destroy + end end end @@ -58,11 +71,15 @@ module Mattermost end def get(path, options = {}) - self.class.get(path, options.merge(headers: @headers)) + handle_exceptions do + self.class.get(path, options.merge(headers: @headers)) + end end def post(path, options = {}) - self.class.post(path, options.merge(headers: @headers)) + handle_exceptions do + self.class.post(path, options.merge(headers: @headers)) + end end private @@ -111,5 +128,33 @@ module Mattermost response.headers['token'] end end + + def with_lease + lease_uuid = lease_try_obtain + raise NoSessionError unless lease_uuid + + begin + yield + ensure + Gitlab::ExclusiveLease.cancel(lease_key, lease_uuid) + end + end + + def lease_key + "mattermost:session" + end + + def lease_try_obtain + lease = ::Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT) + lease.try_obtain + end + + def handle_exceptions + yield + rescue HTTParty::Error => e + raise Mattermost::ConnectionError.new(e.message) + rescue Errno::ECONNREFUSED + raise Mattermost::ConnectionError.new(e.message) + end end end diff --git a/lib/mattermost/team.rb b/lib/mattermost/team.rb new file mode 100644 index 00000000000..784eca6ab5a --- /dev/null +++ b/lib/mattermost/team.rb @@ -0,0 +1,7 @@ +module Mattermost + class Team < Client + def all + json_get('/api/v3/teams/all') + end + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index a763e2c5ba8..98dfb3e5216 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -105,4 +105,25 @@ describe GroupsController do end end end + + describe 'PUT update' do + before do + sign_in(user) + end + + it 'updates the path succesfully' do + post :update, id: group.to_param, group: { path: 'new_path' } + + expect(response).to have_http_status(302) + expect(controller).to set_flash[:notice] + end + + it 'does not update the path on error' do + allow_any_instance_of(Group).to receive(:move_dir).and_raise(Gitlab::UpdatePathError) + post :update, id: group.to_param, group: { path: 'new_path' } + + expect(assigns(:group).errors).not_to be_empty + expect(assigns(:group).path).not_to eq('new_path') + end + end end diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb new file mode 100644 index 00000000000..2ae635a1244 --- /dev/null +++ b/spec/controllers/projects/mattermosts_controller_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Projects::MattermostsController do + let!(:project) { create(:empty_project) } + let!(:user) { create(:user) } + + before do + project.team << [user, :master] + sign_in(user) + end + + describe 'GET #new' do + before do + allow_any_instance_of(MattermostSlashCommandsService). + to receive(:list_teams).and_return([]) + + get(:new, + namespace_id: project.namespace.to_param, + project_id: project.to_param) + end + + it 'accepts the request' do + expect(response).to have_http_status(200) + end + end + + describe 'POST #create' do + let(:mattermost_params) { { trigger: 'http://localhost:3000/trigger', team_id: 'abc' } } + + subject do + post(:create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + mattermost: mattermost_params) + end + + context 'no request can be made to mattermost' do + it 'shows the error' do + allow_any_instance_of(MattermostSlashCommandsService).to receive(:configure).and_return([false, "error message"]) + + expect(subject).to redirect_to(new_namespace_project_mattermost_url(project.namespace, project)) + end + end + + context 'the request is succesull' do + before do + allow_any_instance_of(Mattermost::Command).to receive(:create).and_return('token') + end + + it 'redirects to the new page' do + subject + service = project.services.last + + expect(subject).to redirect_to(edit_namespace_project_service_url(project.namespace, project, service)) + end + end + end +end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index da64827b377..df3a467cbb7 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -39,7 +39,6 @@ feature 'GFM autocomplete', feature: true, js: true do page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys("~#{label.title[0]}") - sleep 1 note.click end @@ -53,7 +52,6 @@ feature 'GFM autocomplete', feature: true, js: true do page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys("@#{user.username[0]}") - sleep 1 note.click end @@ -67,7 +65,6 @@ feature 'GFM autocomplete', feature: true, js: true do page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys(":cartwheel") - sleep 1 note.click end @@ -76,6 +73,22 @@ feature 'GFM autocomplete', feature: true, js: true do expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') end + it 'doesn\'t open autocomplete after non-word character' do + page.within '.timeline-content-form' do + find('#note_note').native.send_keys("@#{user.username[0..2]}!") + end + + expect(page).not_to have_selector('.atwho-view') + end + + it 'doesn\'t open autocomplete if there is no space before' do + page.within '.timeline-content-form' do + find('#note_note').native.send_keys("hello:#{user.username[0..2]}") + end + + expect(page).not_to have_selector('.atwho-view') + end + def expect_to_wrap(should_wrap, item, note, value) expect(item).to have_content(value) expect(item).not_to have_content("\"#{value}\"") @@ -89,12 +102,4 @@ feature 'GFM autocomplete', feature: true, js: true do end end end - - it 'doesnt open autocomplete after non-word character' do - page.within '.timeline-content-form' do - find('#note_note').native.send_keys("@#{user.username[0..2]}!") - end - - expect(page).not_to have_selector('.atwho-view') - end end diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb index f474e7e891b..274d50e7ce4 100644 --- a/spec/features/projects/services/mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/mattermost_slash_command_spec.rb @@ -4,29 +4,26 @@ feature 'Setup Mattermost slash commands', feature: true do include WaitForAjax let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:empty_project) } let(:service) { project.create_mattermost_slash_commands_service } + let(:mattermost_enabled) { true } before do + Settings.mattermost['enabled'] = mattermost_enabled project.team << [user, :master] login_as(user) + visit edit_namespace_project_service_path(project.namespace, project, service) end - describe 'user visites the mattermost slash command config page', js: true do + describe 'user visits the mattermost slash command config page', js: true do it 'shows a help message' do - visit edit_namespace_project_service_path(project.namespace, project, service) - wait_for_ajax expect(page).to have_content("This service allows GitLab users to perform common") end - end - - describe 'saving a token' do - let(:token) { ('a'..'z').to_a.join } it 'shows the token after saving' do - visit edit_namespace_project_service_path(project.namespace, project, service) + token = ('a'..'z').to_a.join fill_in 'service_token', with: token click_on 'Save' @@ -35,14 +32,21 @@ feature 'Setup Mattermost slash commands', feature: true do expect(value).to eq(token) end - end - describe 'the trigger url' do - it 'shows the correct url' do - visit edit_namespace_project_service_path(project.namespace, project, service) + describe 'mattermost service is enabled' do + it 'shows the add to mattermost button' do + expect(page).to have_link 'Add to Mattermost' + end + end + + describe 'mattermost service is not enabled' do + let(:mattermost_enabled) { false } + + it 'shows the correct trigger url' do + value = find_field('request_url').value - value = find_field('request_url').value - expect(value).to match("api/v3/projects/#{project.id}/services/mattermost_slash_commands/trigger") + expect(value).to match("api/v3/projects/#{project.id}/services/mattermost_slash_commands/trigger") + end end end end diff --git a/spec/javascripts/fixtures/issuable_filter.html.haml b/spec/javascripts/fixtures/issuable_filter.html.haml new file mode 100644 index 00000000000..ae745b292e6 --- /dev/null +++ b/spec/javascripts/fixtures/issuable_filter.html.haml @@ -0,0 +1,8 @@ +%form.js-filter-form{action: '/user/project/issues?scope=all&state=closed'} + %input{id: 'utf8', name: 'utf8', value: '✓'} + %input{id: 'check_all_issues', name: 'check_all_issues'} + %input{id: 'search', name: 'search'} + %input{id: 'author_id', name: 'author_id'} + %input{id: 'assignee_id', name: 'assignee_id'} + %input{id: 'milestone_title', name: 'milestone_title'} + %input{id: 'label_name', name: 'label_name'} diff --git a/spec/javascripts/issuable_spec.js.es6 b/spec/javascripts/issuable_spec.js.es6 new file mode 100644 index 00000000000..d61601ee4fb --- /dev/null +++ b/spec/javascripts/issuable_spec.js.es6 @@ -0,0 +1,81 @@ +/* global Issuable */ +/* global Turbolinks */ + +//= require issuable +//= require turbolinks + +(() => { + const BASE_URL = '/user/project/issues?scope=all&state=closed'; + const DEFAULT_PARAMS = '&utf8=%E2%9C%93'; + + function updateForm(formValues, form) { + $.each(formValues, (id, value) => { + $(`#${id}`, form).val(value); + }); + } + + function resetForm(form) { + $('input[name!="utf8"]', form).each((index, input) => { + input.setAttribute('value', ''); + }); + } + + describe('Issuable', () => { + fixture.preload('issuable_filter'); + + beforeEach(() => { + fixture.load('issuable_filter'); + Issuable.init(); + }); + + it('should be defined', () => { + expect(window.Issuable).toBeDefined(); + }); + + describe('filtering', () => { + let $filtersForm; + + beforeEach(() => { + $filtersForm = $('.js-filter-form'); + fixture.load('issuable_filter'); + resetForm($filtersForm); + }); + + it('should contain only the default parameters', () => { + spyOn(Turbolinks, 'visit'); + + Issuable.filterResults($filtersForm); + + expect(Turbolinks.visit).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS); + }); + + it('should filter for the phrase "broken"', () => { + spyOn(Turbolinks, 'visit'); + + updateForm({ search: 'broken' }, $filtersForm); + Issuable.filterResults($filtersForm); + const params = `${DEFAULT_PARAMS}&search=broken`; + + expect(Turbolinks.visit).toHaveBeenCalledWith(BASE_URL + params); + }); + + it('should keep query parameters after modifying filter', () => { + spyOn(Turbolinks, 'visit'); + + // initial filter + updateForm({ milestone_title: 'v1.0' }, $filtersForm); + + Issuable.filterResults($filtersForm); + let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`; + expect(Turbolinks.visit).toHaveBeenCalledWith(BASE_URL + params); + + // update filter + updateForm({ label_name: 'Frontend' }, $filtersForm); + + Issuable.filterResults($filtersForm); + params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`; + expect(Turbolinks.visit).toHaveBeenCalledWith(BASE_URL + params); + }); + }); + }); +})(); diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb index 167397c736b..d9e4525cb28 100644 --- a/spec/lib/banzai/filter/external_link_filter_spec.rb +++ b/spec/lib/banzai/filter/external_link_filter_spec.rb @@ -80,4 +80,18 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do expect(filter(act).to_html).to eq(exp) end end + + context 'for protocol-relative links' do + let(:doc) { filter %q(<p><a href="//google.com/">Google</a></p>) } + + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end + + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end + end end diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index bcaffd27909..7371b578a48 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::Metrics::RackMiddleware do end it 'tags a transaction with the method and path of the route in the grape endpoint' do - route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + route = double(:route, request_method: "GET", path: "/:version/projects/:id/archive(.:format)") endpoint = double(:endpoint, route: route) env['api.endpoint'] = endpoint @@ -117,7 +117,7 @@ describe Gitlab::Metrics::RackMiddleware do let(:transaction) { middleware.transaction_from_env(env) } it 'tags a transaction with the method and path of the route in the grape endpount' do - route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + route = double(:route, request_method: "GET", path: "/:version/projects/:id/archive(.:format)") endpoint = double(:endpoint, route: route) env['api.endpoint'] = endpoint diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb index ab1ab22795c..8d925460f01 100644 --- a/spec/lib/gitlab/middleware/multipart_spec.rb +++ b/spec/lib/gitlab/middleware/multipart_spec.rb @@ -12,7 +12,7 @@ describe Gitlab::Middleware::Multipart do expect(app).to receive(:call) do |env| file = Rack::Request.new(env).params['file'] - expect(file).to be_a(File) + expect(file).to be_a(::UploadedFile) expect(file.path).to eq(tempfile.path) end @@ -39,7 +39,7 @@ describe Gitlab::Middleware::Multipart do expect(app).to receive(:call) do |env| file = Rack::Request.new(env).params['user']['avatar'] - expect(file).to be_a(File) + expect(file).to be_a(::UploadedFile) expect(file.path).to eq(tempfile.path) end @@ -54,7 +54,7 @@ describe Gitlab::Middleware::Multipart do expect(app).to receive(:call) do |env| file = Rack::Request.new(env).params['project']['milestone']['themesong'] - expect(file).to be_a(File) + expect(file).to be_a(::UploadedFile) expect(file.path).to eq(tempfile.path) end diff --git a/spec/lib/mattermost/client_spec.rb b/spec/lib/mattermost/client_spec.rb new file mode 100644 index 00000000000..dc11a414717 --- /dev/null +++ b/spec/lib/mattermost/client_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Mattermost::Client do + let(:user) { build(:user) } + + subject { described_class.new(user) } + + context 'JSON parse error' do + before do + Struct.new("Request", :body, :success?) + end + + it 'yields an error on malformed JSON' do + bad_json = Struct::Request.new("I'm not json", true) + expect { subject.send(:json_response, bad_json) }.to raise_error(Mattermost::ClientError) + end + + it 'shows a client error if the request was unsuccessful' do + bad_request = Struct::Request.new("true", false) + + expect { subject.send(:json_response, bad_request) }.to raise_error(Mattermost::ClientError) + end + end +end diff --git a/spec/lib/mattermost/command_spec.rb b/spec/lib/mattermost/command_spec.rb new file mode 100644 index 00000000000..5ccf1100898 --- /dev/null +++ b/spec/lib/mattermost/command_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Mattermost::Command do + let(:params) { { 'token' => 'token', team_id: 'abc' } } + + before do + Mattermost::Session.base_uri('http://mattermost.example.com') + + allow_any_instance_of(Mattermost::Client).to receive(:with_session). + and_yield(Mattermost::Session.new(nil)) + end + + describe '#create' do + let(:params) do + { team_id: 'abc', + trigger: 'gitlab' + } + end + + subject { described_class.new(nil).create(params) } + + context 'for valid trigger word' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create'). + with(body: { + team_id: 'abc', + trigger: 'gitlab' }.to_json). + to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: { token: 'token' }.to_json + ) + end + + it 'returns a token' do + is_expected.to eq('token') + end + end + + context 'for error message' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create'). + to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + id: 'api.command.duplicate_trigger.app_error', + message: 'This trigger word is already in use. Please choose another word.', + detailed_error: '', + request_id: 'obc374man7bx5r3dbc1q5qhf3r', + status_code: 500 + }.to_json + ) + end + + it 'raises an error with message' do + expect { subject }.to raise_error(Mattermost::Error, 'This trigger word is already in use. Please choose another word.') + end + end + end +end diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index 3c2eddbd221..74d12e37181 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -95,5 +95,29 @@ describe Mattermost::Session, type: :request do end end end + + context 'with lease' do + before do + allow(subject).to receive(:lease_try_obtain).and_return('aldkfjsldfk') + end + + it 'tries to obtain a lease' do + expect(subject).to receive(:lease_try_obtain) + expect(Gitlab::ExclusiveLease).to receive(:cancel) + + # Cannot setup a session, but we should still cancel the lease + expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) + end + end + + context 'without lease' do + before do + allow(subject).to receive(:lease_try_obtain).and_return(nil) + end + + it 'returns a NoSessionError error' do + expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) + end + end end end diff --git a/spec/lib/mattermost/team_spec.rb b/spec/lib/mattermost/team_spec.rb new file mode 100644 index 00000000000..2d14be6bcc2 --- /dev/null +++ b/spec/lib/mattermost/team_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Mattermost::Team do + before do + Mattermost::Session.base_uri('http://mattermost.example.com') + + allow_any_instance_of(Mattermost::Client).to receive(:with_session). + and_yield(Mattermost::Session.new(nil)) + end + + describe '#all' do + subject { described_class.new(nil).all } + + context 'for valid request' do + let(:response) do + [{ + "id" => "xiyro8huptfhdndadpz8r3wnbo", + "create_at" => 1482174222155, + "update_at" => 1482174222155, + "delete_at" => 0, + "display_name" => "chatops", + "name" => "chatops", + "email" => "admin@example.com", + "type" => "O", + "company_name" => "", + "allowed_domains" => "", + "invite_id" => "o4utakb9jtb7imctdfzbf9r5ro", + "allow_open_invite" => false }] + end + + before do + stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all'). + to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: response.to_json + ) + end + + it 'returns a token' do + is_expected.to eq(response) + end + end + + context 'for error message' do + before do + stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all'). + to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + id: 'api.team.list.app_error', + message: 'Cannot list teams.', + detailed_error: '', + request_id: 'obc374man7bx5r3dbc1q5qhf3r', + status_code: 500 + }.to_json + ) + end + + it 'raises an error with message' do + expect { subject }.to raise_error(Mattermost::Error, 'Cannot list teams.') + end + end + end +end diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb index 1ae1483e2a4..d6f4fbd7265 100644 --- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -2,4 +2,123 @@ require 'spec_helper' describe MattermostSlashCommandsService, :models do it_behaves_like "chat slash commands service" + + context 'Mattermost API' do + let(:project) { create(:empty_project) } + let(:service) { project.build_mattermost_slash_commands_service } + let(:user) { create(:user)} + + before do + Mattermost::Session.base_uri("http://mattermost.example.com") + + allow_any_instance_of(Mattermost::Client).to receive(:with_session). + and_yield(Mattermost::Session.new(nil)) + end + + describe '#configure' do + subject do + service.configure(user, team_id: 'abc', + trigger: 'gitlab', url: 'http://trigger.url', + icon_url: 'http://icon.url/icon.png') + end + + context 'the requests succeeds' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create'). + with(body: { + team_id: 'abc', + trigger: 'gitlab', + url: 'http://trigger.url', + icon_url: 'http://icon.url/icon.png', + auto_complete: true, + auto_complete_desc: "Perform common operations on: #{project.name_with_namespace}", + auto_complete_hint: '[help]', + description: "Perform common operations on: #{project.name_with_namespace}", + display_name: "GitLab / #{project.name_with_namespace}", + method: 'P', + user_name: 'GitLab' }.to_json). + to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: { token: 'token' }.to_json + ) + end + + it 'saves the service' do + expect { subject }.to change { project.services.count }.by(1) + end + + it 'saves the token' do + subject + + expect(service.reload.token).to eq('token') + end + end + + context 'an error is received' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create'). + to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + id: 'api.command.duplicate_trigger.app_error', + message: 'This trigger word is already in use. Please choose another word.', + detailed_error: '', + request_id: 'obc374man7bx5r3dbc1q5qhf3r', + status_code: 500 + }.to_json + ) + end + + it 'shows error messages' do + succeeded, message = subject + + expect(succeeded).to be(false) + expect(message).to eq('This trigger word is already in use. Please choose another word.') + end + end + end + + describe '#list_teams' do + subject do + service.list_teams(user) + end + + context 'the requests succeeds' do + before do + stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all'). + to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: ['list'].to_json + ) + end + + it 'returns a list of teams' do + expect(subject).not_to be_empty + end + end + + context 'an error is received' do + before do + stub_request(:get, 'http://mattermost.example.com/api/v3/teams/all'). + to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + message: 'Failed to get team list.' + }.to_json + ) + end + + it 'shows error messages' do + teams, message = subject + + expect(teams).to be_empty + expect(message).to eq('Failed to get team list.') + end + end + end + end end diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index 9c2331144a0..531180e48a1 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -1,15 +1,15 @@ require 'spec_helper' describe Groups::UpdateService, services: true do - let!(:user) { create(:user) } - let!(:private_group) { create(:group, :private) } - let!(:internal_group) { create(:group, :internal) } - let!(:public_group) { create(:group, :public) } + let!(:user) { create(:user) } + let!(:private_group) { create(:group, :private) } + let!(:internal_group) { create(:group, :internal) } + let!(:public_group) { create(:group, :public) } describe "#execute" do context "project visibility_level validation" do context "public group with public projects" do - let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL ) } + let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } before do public_group.add_user(user, Gitlab::Access::MASTER) @@ -23,7 +23,7 @@ describe Groups::UpdateService, services: true do end context "internal group with internal project" do - let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE ) } + let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } before do internal_group.add_user(user, Gitlab::Access::MASTER) @@ -39,7 +39,7 @@ describe Groups::UpdateService, services: true do end context "unauthorized visibility_level validation" do - let!(:service) { described_class.new(internal_group, user, visibility_level: 99 ) } + let!(:service) { described_class.new(internal_group, user, visibility_level: 99) } before do internal_group.add_user(user, Gitlab::Access::MASTER) end @@ -49,4 +49,41 @@ describe Groups::UpdateService, services: true do expect(internal_group.errors.count).to eq(1) end end + + context 'rename group' do + let!(:service) { described_class.new(internal_group, user, path: 'new_path') } + + before do + internal_group.add_user(user, Gitlab::Access::MASTER) + create(:project, :internal, group: internal_group) + end + + it 'returns true' do + expect(service.execute).to eq(true) + end + + context 'error moving group' do + before do + allow(internal_group).to receive(:move_dir).and_raise(Gitlab::UpdatePathError) + end + + it 'does not raise an error' do + expect { service.execute }.not_to raise_error + end + + it 'returns false' do + expect(service.execute).to eq(false) + end + + it 'has the right error' do + service.execute + + expect(internal_group.errors.full_messages.first).to eq('Gitlab::UpdatePathError') + end + + it "hasn't changed the path" do + expect { service.execute}.not_to change { internal_group.reload.path} + end + end + end end |