Commit 16d9f66e authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 5333cb6c
...@@ -59,6 +59,7 @@ eslint-report.html ...@@ -59,6 +59,7 @@ eslint-report.html
/public/uploads.* /public/uploads.*
/public/uploads/ /public/uploads/
/shared/artifacts/ /shared/artifacts/
/spec/examples.txt
/rails_best_practices_output.html /rails_best_practices_output.html
/tags /tags
/vendor/bundle/* /vendor/bundle/*
......
...@@ -66,3 +66,4 @@ schedule:package-and-qa: ...@@ -66,3 +66,4 @@ schedule:package-and-qa:
- .default-only - .default-only
- .only:variables_refs-canonical-dot-com-schedules - .only:variables_refs-canonical-dot-com-schedules
needs: ["build-qa-image", "gitlab:assets:compile pull-cache"] needs: ["build-qa-image", "gitlab:assets:compile pull-cache"]
allow_failure: true
...@@ -64,7 +64,7 @@ gem 'u2f', '~> 0.2.1' ...@@ -64,7 +64,7 @@ gem 'u2f', '~> 0.2.1'
# GitLab Pages # GitLab Pages
gem 'validates_hostname', '~> 1.0.6' gem 'validates_hostname', '~> 1.0.6'
gem 'rubyzip', '~> 1.2.2', require: 'zip' gem 'rubyzip', '~> 1.3.0', require: 'zip'
# GitLab Pages letsencrypt support # GitLab Pages letsencrypt support
gem 'acme-client', '~> 2.0.2' gem 'acme-client', '~> 2.0.2'
...@@ -72,7 +72,7 @@ gem 'acme-client', '~> 2.0.2' ...@@ -72,7 +72,7 @@ gem 'acme-client', '~> 2.0.2'
gem 'browser', '~> 2.5' gem 'browser', '~> 2.5'
# GPG # GPG
gem 'gpgme', '~> 2.0.18' gem 'gpgme', '~> 2.0.19'
# LDAP Auth # LDAP Auth
# GitLab fork with several improvements to original library. For full list of changes # GitLab fork with several improvements to original library. For full list of changes
...@@ -151,7 +151,7 @@ gem 'asciidoctor-plantuml', '0.0.9' ...@@ -151,7 +151,7 @@ gem 'asciidoctor-plantuml', '0.0.9'
gem 'rouge', '~> 3.11.0' gem 'rouge', '~> 3.11.0'
gem 'truncato', '~> 0.7.11' gem 'truncato', '~> 0.7.11'
gem 'bootstrap_form', '~> 4.2.0' gem 'bootstrap_form', '~> 4.2.0'
gem 'nokogiri', '~> 1.10.4' gem 'nokogiri', '~> 1.10.5'
gem 'escape_utils', '~> 1.1' gem 'escape_utils', '~> 1.1'
# Calendar rendering # Calendar rendering
......
...@@ -411,7 +411,7 @@ GEM ...@@ -411,7 +411,7 @@ GEM
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (~> 0.7) signet (~> 0.7)
gpgme (2.0.18) gpgme (2.0.19)
mini_portile2 (~> 2.3) mini_portile2 (~> 2.3)
grape (1.1.0) grape (1.1.0)
activesupport activesupport
...@@ -902,7 +902,7 @@ GEM ...@@ -902,7 +902,7 @@ GEM
sexp_processor (~> 4.9) sexp_processor (~> 4.9)
rubyntlm (0.6.2) rubyntlm (0.6.2)
rubypants (0.2.0) rubypants (0.2.0)
rubyzip (1.2.2) rubyzip (1.3.0)
rugged (0.28.3.1) rugged (0.28.3.1)
safe_yaml (1.0.4) safe_yaml (1.0.4)
sanitize (4.6.6) sanitize (4.6.6)
...@@ -1183,7 +1183,7 @@ DEPENDENCIES ...@@ -1183,7 +1183,7 @@ DEPENDENCIES
gon (~> 6.2) gon (~> 6.2)
google-api-client (~> 0.23) google-api-client (~> 0.23)
google-protobuf (~> 3.8.0) google-protobuf (~> 3.8.0)
gpgme (~> 2.0.18) gpgme (~> 2.0.19)
grape (~> 1.1.0) grape (~> 1.1.0)
grape-entity (~> 0.7.1) grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.1) grape-path-helpers (~> 1.1)
...@@ -1227,7 +1227,7 @@ DEPENDENCIES ...@@ -1227,7 +1227,7 @@ DEPENDENCIES
net-ldap net-ldap
net-ntp net-ntp
net-ssh (~> 5.2) net-ssh (~> 5.2)
nokogiri (~> 1.10.4) nokogiri (~> 1.10.5)
oauth2 (~> 1.4) oauth2 (~> 1.4)
octokit (~> 4.9) octokit (~> 4.9)
omniauth (~> 1.8) omniauth (~> 1.8)
...@@ -1293,7 +1293,7 @@ DEPENDENCIES ...@@ -1293,7 +1293,7 @@ DEPENDENCIES
ruby-prof (~> 1.0.0) ruby-prof (~> 1.0.0)
ruby-progressbar ruby-progressbar
ruby_parser (~> 3.8) ruby_parser (~> 3.8)
rubyzip (~> 1.2.2) rubyzip (~> 1.3.0)
rugged (~> 0.28) rugged (~> 0.28)
sanitize (~> 4.6) sanitize (~> 4.6)
sassc-rails (~> 2.1.0) sassc-rails (~> 2.1.0)
......
...@@ -54,6 +54,11 @@ export default { ...@@ -54,6 +54,11 @@ export default {
}, },
}, },
computed: { computed: {
milestoneLink() {
const { title } = this.issuable.milestone;
return this.issuableLink({ milestone_title: title });
},
hasLabels() { hasLabels() {
return Boolean(this.issuable.labels && this.issuable.labels.length); return Boolean(this.issuable.labels && this.issuable.labels.length);
}, },
...@@ -167,8 +172,11 @@ export default { ...@@ -167,8 +172,11 @@ export default {
color: label.text_color, color: label.text_color,
}; };
}, },
issuableLink(params) {
return mergeUrlParams(params, this.baseUrl);
},
labelHref({ name }) { labelHref({ name }) {
return mergeUrlParams({ 'label_name[]': name }, this.baseUrl); return this.issuableLink({ 'label_name[]': name });
}, },
onSelect(ev) { onSelect(ev) {
this.$emit('select', { this.$emit('select', {
...@@ -216,9 +224,9 @@ export default { ...@@ -216,9 +224,9 @@ export default {
></i> ></i>
<gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
</span> </span>
<span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block"> <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">{{
{{ issuable.task_status }} issuable.task_status
</span> }}</span>
</div> </div>
<div class="issuable-info"> <div class="issuable-info">
...@@ -233,7 +241,7 @@ export default { ...@@ -233,7 +241,7 @@ export default {
v-if="issuable.milestone" v-if="issuable.milestone"
v-gl-tooltip v-gl-tooltip
class="d-none d-sm-inline-block mr-1 js-milestone" class="d-none d-sm-inline-block mr-1 js-milestone"
:href="issuable.milestone.web_url" :href="milestoneLink"
:title="milestoneTooltipText" :title="milestoneTooltipText"
> >
<i class="fa fa-clock-o"></i> <i class="fa fa-clock-o"></i>
......
/* eslint-disable no-useless-escape, no-var, no-underscore-dangle, func-names, no-return-assign, one-var, consistent-return, class-methods-use-this */ /* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery'; import $ from 'jquery';
import 'cropper'; import 'cropper';
...@@ -59,8 +59,7 @@ import _ from 'underscore'; ...@@ -59,8 +59,7 @@ import _ from 'underscore';
} }
bindEvents() { bindEvents() {
var _this; const _this = this;
_this = this;
this.fileInput.on('change', function(e) { this.fileInput.on('change', function(e) {
_this.onFileInputChange(e, this); _this.onFileInputChange(e, this);
this.value = null; this.value = null;
...@@ -70,8 +69,7 @@ import _ from 'underscore'; ...@@ -70,8 +69,7 @@ import _ from 'underscore';
this.modalCrop.on('hidden.bs.modal', this.onModalHide); this.modalCrop.on('hidden.bs.modal', this.onModalHide);
this.uploadImageBtn.on('click', this.onUploadImageBtnClick); this.uploadImageBtn.on('click', this.onUploadImageBtnClick);
this.cropActionsBtn.on('click', function() { this.cropActionsBtn.on('click', function() {
var btn; const btn = this;
btn = this;
return _this.onActionBtnClick(btn); return _this.onActionBtnClick(btn);
}); });
return (this.croppedImageBlob = null); return (this.croppedImageBlob = null);
...@@ -82,8 +80,7 @@ import _ from 'underscore'; ...@@ -82,8 +80,7 @@ import _ from 'underscore';
} }
onModalShow() { onModalShow() {
var _this; const _this = this;
_this = this;
return this.modalCropImg.cropper({ return this.modalCropImg.cropper({
viewMode: 1, viewMode: 1,
center: false, center: false,
...@@ -128,8 +125,7 @@ import _ from 'underscore'; ...@@ -128,8 +125,7 @@ import _ from 'underscore';
} }
onActionBtnClick(btn) { onActionBtnClick(btn) {
var data; const data = $(btn).data();
data = $(btn).data();
if (this.modalCropImg.data('cropper') && data.method) { if (this.modalCropImg.data('cropper') && data.method) {
return this.modalCropImg.cropper(data.method, data.option); return this.modalCropImg.cropper(data.method, data.option);
} }
...@@ -140,9 +136,8 @@ import _ from 'underscore'; ...@@ -140,9 +136,8 @@ import _ from 'underscore';
} }
readFile(input) { readFile(input) {
var _this, reader; const _this = this;
_this = this; const reader = new FileReader();
reader = new FileReader();
reader.onload = () => { reader.onload = () => {
_this.modalCropImg.attr('src', reader.result); _this.modalCropImg.attr('src', reader.result);
return _this.modalCrop.modal('show'); return _this.modalCrop.modal('show');
...@@ -151,9 +146,10 @@ import _ from 'underscore'; ...@@ -151,9 +146,10 @@ import _ from 'underscore';
} }
dataURLtoBlob(dataURL) { dataURLtoBlob(dataURL) {
var array, binary, i, len; let i = 0;
binary = atob(dataURL.split(',')[1]); let len = 0;
array = []; const binary = atob(dataURL.split(',')[1]);
const array = [];
for (i = 0, len = binary.length; i < len; i += 1) { for (i = 0, len = binary.length; i < len; i += 1) {
array.push(binary.charCodeAt(i)); array.push(binary.charCodeAt(i));
...@@ -164,9 +160,8 @@ import _ from 'underscore'; ...@@ -164,9 +160,8 @@ import _ from 'underscore';
} }
setPreview() { setPreview() {
var filename; const filename = this.fileInput.val().replace(FILENAMEREGEX, '');
this.previewImage.attr('src', this.dataURL); this.previewImage.attr('src', this.dataURL);
filename = this.fileInput.val().replace(FILENAMEREGEX, '');
return this.filename.text(filename); return this.filename.text(filename);
} }
......
...@@ -33,6 +33,8 @@ export default { ...@@ -33,6 +33,8 @@ export default {
<div class="block subscriptions"> <div class="block subscriptions">
<subscriptions <subscriptions
:loading="store.isFetching.subscriptions" :loading="store.isFetching.subscriptions"
:project-emails-disabled="store.projectEmailsDisabled"
:subscribe-disabled-description="store.subscribeDisabledDescription"
:subscribed="store.subscribed" :subscribed="store.subscribed"
@toggleSubscription="onToggleSubscription" @toggleSubscription="onToggleSubscription"
/> />
......
...@@ -26,6 +26,16 @@ export default { ...@@ -26,6 +26,16 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
projectEmailsDisabled: {
type: Boolean,
required: false,
default: false,
},
subscribeDisabledDescription: {
type: String,
required: false,
default: '',
},
subscribed: { subscribed: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -42,11 +52,23 @@ export default { ...@@ -42,11 +52,23 @@ export default {
return this.subscribed === null; return this.subscribed === null;
}, },
notificationIcon() { notificationIcon() {
if (this.projectEmailsDisabled) {
return ICON_OFF;
}
return this.subscribed ? ICON_ON : ICON_OFF; return this.subscribed ? ICON_ON : ICON_OFF;
}, },
notificationTooltip() { notificationTooltip() {
if (this.projectEmailsDisabled) {
return this.subscribeDisabledDescription;
}
return this.subscribed ? LABEL_ON : LABEL_OFF; return this.subscribed ? LABEL_ON : LABEL_OFF;
}, },
notificationText() {
if (this.projectEmailsDisabled) {
return this.subscribeDisabledDescription;
}
return __('Notifications');
},
}, },
methods: { methods: {
/** /**
...@@ -81,6 +103,7 @@ export default { ...@@ -81,6 +103,7 @@ export default {
<template> <template>
<div> <div>
<span <span
ref="tooltip"
v-tooltip v-tooltip
class="sidebar-collapsed-icon" class="sidebar-collapsed-icon"
:title="notificationTooltip" :title="notificationTooltip"
...@@ -96,8 +119,9 @@ export default { ...@@ -96,8 +119,9 @@ export default {
class="sidebar-item-icon is-active" class="sidebar-item-icon is-active"
/> />
</span> </span>
<span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span> <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span>
<toggle-button <toggle-button
v-if="!projectEmailsDisabled"
ref="toggleButton" ref="toggleButton"
:is-loading="showLoadingState" :is-loading="showLoadingState"
:value="subscribed" :value="subscribed"
......
...@@ -28,6 +28,8 @@ export default class SidebarStore { ...@@ -28,6 +28,8 @@ export default class SidebarStore {
this.moveToProjectId = 0; this.moveToProjectId = 0;
this.isLockDialogOpen = false; this.isLockDialogOpen = false;
this.participants = []; this.participants = [];
this.projectEmailsDisabled = false;
this.subscribeDisabledDescription = '';
this.subscribed = null; this.subscribed = null;
SidebarStore.singleton = this; SidebarStore.singleton = this;
...@@ -53,6 +55,8 @@ export default class SidebarStore { ...@@ -53,6 +55,8 @@ export default class SidebarStore {
} }
setSubscriptionsData(data) { setSubscriptionsData(data) {
this.projectEmailsDisabled = data.project_emails_disabled || false;
this.subscribeDisabledDescription = data.subscribe_disabled_description;
this.isFetching.subscriptions = false; this.isFetching.subscriptions = false;
this.subscribed = data.subscribed || false; this.subscribed = data.subscribed || false;
} }
......
...@@ -20,11 +20,11 @@ class ApplicationController < ActionController::Base ...@@ -20,11 +20,11 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user!, except: [:route_not_found] before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms? before_action :enforce_terms!, if: :should_enforce_terms?
before_action :validate_user_service_ticket! before_action :validate_user_service_ticket!
before_action :check_password_expiration, if: :html_request? before_action :check_password_expiration
before_action :ldap_security_check before_action :ldap_security_check
before_action :sentry_context before_action :sentry_context
before_action :default_headers before_action :default_headers
before_action :add_gon_variables, if: :html_request? before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller? before_action :require_email, unless: :devise_controller?
before_action :active_user_check, unless: :devise_controller? before_action :active_user_check, unless: :devise_controller?
...@@ -455,8 +455,8 @@ class ApplicationController < ActionController::Base ...@@ -455,8 +455,8 @@ class ApplicationController < ActionController::Base
response.headers['Page-Title'] = URI.escape(page_title('GitLab')) response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
end end
def html_request? def peek_request?
request.format.html? request.path.start_with?('/-/peek')
end end
def json_request? def json_request?
...@@ -466,7 +466,7 @@ class ApplicationController < ActionController::Base ...@@ -466,7 +466,7 @@ class ApplicationController < ActionController::Base
def should_enforce_terms? def should_enforce_terms?
return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms
html_request? && !devise_controller? !(peek_request? || devise_controller?)
end end
def set_usage_stats_consent_flag def set_usage_stats_consent_flag
......
...@@ -4,18 +4,15 @@ module ConfirmEmailWarning ...@@ -4,18 +4,15 @@ module ConfirmEmailWarning
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_action :set_confirm_warning, if: :show_confirm_warning? before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) }
end end
protected protected
def show_confirm_warning?
html_request? && request.get? && Feature.enabled?(:soft_email_confirmation)
end
def set_confirm_warning def set_confirm_warning
return unless current_user return unless current_user
return if current_user.confirmed? return if current_user.confirmed?
return if peek_request? || json_request? || !request.get?
email = current_user.unconfirmed_email || current_user.email email = current_user.unconfirmed_email || current_user.email
......
...@@ -4,7 +4,7 @@ module SourcegraphGon ...@@ -4,7 +4,7 @@ module SourcegraphGon
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_action :push_sourcegraph_gon, if: :html_request? before_action :push_sourcegraph_gon, unless: :json_request?
end end
private private
......
# frozen_string_literal: true # frozen_string_literal: true
module UploadsActions module UploadsActions
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include SendFileUpload include SendFileUpload
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
included do
prepend_before_action :set_request_format_from_path_extension
end
def create def create
uploader = UploadService.new(model, params[:file], uploader_class).execute uploader = UploadService.new(model, params[:file], uploader_class).execute
...@@ -69,18 +64,6 @@ module UploadsActions ...@@ -69,18 +64,6 @@ module UploadsActions
private private
# From ActionDispatch::Http::MimeNegotiation. We have an initializer that
# monkey-patches this method out (so that repository paths don't guess a
# format based on extension), but we do want this behaviour when serving
# uploads.
def set_request_format_from_path_extension
path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO']
if match = path&.match(/\.(\w+)\z/)
request.format = match.captures.first
end
end
def uploader_class def uploader_class
raise NotImplementedError raise NotImplementedError
end end
......
...@@ -4,6 +4,9 @@ class Environment < ApplicationRecord ...@@ -4,6 +4,9 @@ class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include ReactiveCaching include ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
belongs_to :project, required: true belongs_to :project, required: true
has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
......
...@@ -3,11 +3,20 @@ ...@@ -3,11 +3,20 @@
class IssuableSidebarExtrasEntity < Grape::Entity class IssuableSidebarExtrasEntity < Grape::Entity
include RequestAwareEntity include RequestAwareEntity
include TimeTrackableEntity include TimeTrackableEntity
include NotificationsHelper
expose :participants, using: ::API::Entities::UserBasic do |issuable| expose :participants, using: ::API::Entities::UserBasic do |issuable|
issuable.participants(request.current_user) issuable.participants(request.current_user)
end end
expose :project_emails_disabled do |issuable|
issuable.project.emails_disabled?
end
expose :subscribe_disabled_description do |issuable|
notification_description(:owner_disabled)
end
expose :subscribed do |issuable| expose :subscribed do |issuable|
issuable.subscribed?(request.current_user, issuable.project) issuable.subscribed?(request.current_user, issuable.project)
end end
......
...@@ -11,19 +11,21 @@ module MergeRequests ...@@ -11,19 +11,21 @@ module MergeRequests
private private
def commit def commit
repository.ff_merge(current_user, ff_merge = repository.ff_merge(current_user,
source, source,
merge_request.target_branch, merge_request.target_branch,
merge_request: merge_request) merge_request: merge_request)
if merge_request.squash
merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha)
end
ff_merge
rescue Gitlab::Git::PreReceiveError => e rescue Gitlab::Git::PreReceiveError => e
raise MergeError, e.message raise MergeError, e.message
rescue StandardError => e rescue StandardError => e
raise MergeError, "Something went wrong during merge: #{e.message}" raise MergeError, "Something went wrong during merge: #{e.message}"
ensure ensure
if merge_request.squash
merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha)
end
merge_request.update(in_progress_merge_commit_sha: nil) merge_request.update(in_progress_merge_commit_sha: nil)
end end
end end
......
...@@ -141,12 +141,6 @@ ...@@ -141,12 +141,6 @@
.js-sidebar-participants-entry-point .js-sidebar-participants-entry-point
- if signed_in - if signed_in
- if issuable_sidebar[:project_emails_disabled]
.block.js-emails-disabled
.sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } }
= notification_setting_icon
.hide-collapsed= notification_description(:owner_disabled)
- else
.js-sidebar-subscriptions-entry-point .js-sidebar-subscriptions-entry-point
- project_ref = issuable_sidebar[:reference] - project_ref = issuable_sidebar[:reference]
......
---
title: Refactor disabled sidebar notifications to Vue
merge_request: 20007
author: minghuan lei
type: other
---
title: Update the DB schema to allow linking between Vulnerabilities and Issues
merge_request: 19852
author:
type: added
---
title: Adds a copy button next to package metadata on the details page
merge_request: 19881
author:
type: added
---
title: Update squash_commit_sha only on successful merge
merge_request: 19688
author:
type: fixed
---
title: Add dead jobs to Sidekiq metrics API
merge_request: 19350
author: Marco Peterseil
type: added
---
title: Fix sub group export to export direct children
merge_request: 20172
author:
type: fixed
---
title: Users can verify SAML configuration and view SamlResponse XML
merge_request: 18362
author:
type: added
# frozen_string_literal: true
class CreateVulnerabilityIssueLinks < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :vulnerability_issue_links do |t|
# index: false because idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id refers the same column
t.references :vulnerability, null: false, index: false, foreign_key: { on_delete: :cascade }
# index: true is implied
t.references :issue, null: false, foreign_key: { on_delete: :cascade }
t.integer 'link_type', limit: 2, null: false, default: 1 # 'related'
t.index %i[vulnerability_id issue_id],
name: 'idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id',
unique: true # only one link (and of only one type) is allowed
t.index %i[vulnerability_id link_type],
name: 'idx_vulnerability_issue_links_on_vulnerability_id_and_link_type',
where: 'link_type = 2',
unique: true # only one 'created' link per vulnerability is allowed
t.timestamps_with_timezone
end
end
end
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_11_14_173624) do ActiveRecord::Schema.define(version: 2019_11_15_091425) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
...@@ -4018,6 +4018,17 @@ ActiveRecord::Schema.define(version: 2019_11_14_173624) do ...@@ -4018,6 +4018,17 @@ ActiveRecord::Schema.define(version: 2019_11_14_173624) do
t.index ["project_id", "fingerprint"], name: "index_vulnerability_identifiers_on_project_id_and_fingerprint", unique: true t.index ["project_id", "fingerprint"], name: "index_vulnerability_identifiers_on_project_id_and_fingerprint", unique: true
end end
create_table "vulnerability_issue_links", force: :cascade do |t|
t.bigint "vulnerability_id", null: false
t.bigint "issue_id", null: false
t.integer "link_type", limit: 2, default: 1, null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.index ["issue_id"], name: "index_vulnerability_issue_links_on_issue_id"
t.index ["vulnerability_id", "issue_id"], name: "idx_vulnerability_issue_links_on_vulnerability_id_and_issue_id", unique: true
t.index ["vulnerability_id", "link_type"], name: "idx_vulnerability_issue_links_on_vulnerability_id_and_link_type", unique: true, where: "(link_type = 2)"
end
create_table "vulnerability_occurrence_identifiers", force: :cascade do |t| create_table "vulnerability_occurrence_identifiers", force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false t.datetime_with_timezone "updated_at", null: false
...@@ -4547,6 +4558,8 @@ ActiveRecord::Schema.define(version: 2019_11_14_173624) do ...@@ -4547,6 +4558,8 @@ ActiveRecord::Schema.define(version: 2019_11_14_173624) do
add_foreign_key "vulnerability_feedback", "users", column: "author_id", on_delete: :cascade add_foreign_key "vulnerability_feedback", "users", column: "author_id", on_delete: :cascade
add_foreign_key "vulnerability_feedback", "users", column: "comment_author_id", name: "fk_94f7c8a81e", on_delete: :nullify add_foreign_key "vulnerability_feedback", "users", column: "comment_author_id", name: "fk_94f7c8a81e", on_delete: :nullify
add_foreign_key "vulnerability_identifiers", "projects", on_delete: :cascade add_foreign_key "vulnerability_identifiers", "projects", on_delete: :cascade
add_foreign_key "vulnerability_issue_links", "issues", on_delete: :cascade
add_foreign_key "vulnerability_issue_links", "vulnerabilities", on_delete: :cascade
add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_identifiers", column: "identifier_id", on_delete: :cascade add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_identifiers", column: "identifier_id", on_delete: :cascade
add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_occurrences", column: "occurrence_id", on_delete: :cascade add_foreign_key "vulnerability_occurrence_identifiers", "vulnerability_occurrences", column: "occurrence_id", on_delete: :cascade
add_foreign_key "vulnerability_occurrence_pipelines", "ci_pipelines", column: "pipeline_id", on_delete: :cascade add_foreign_key "vulnerability_occurrence_pipelines", "ci_pipelines", column: "pipeline_id", on_delete: :cascade
......
...@@ -92,7 +92,8 @@ Example response: ...@@ -92,7 +92,8 @@ Example response:
"jobs": { "jobs": {
"processed": 2, "processed": 2,
"failed": 0, "failed": 0,
"enqueued": 0 "enqueued": 0,
"dead": 0
} }
} }
``` ```
...@@ -145,7 +146,8 @@ Example response: ...@@ -145,7 +146,8 @@ Example response:
"jobs": { "jobs": {
"processed": 2, "processed": 2,
"failed": 0, "failed": 0,
"enqueued": 0 "enqueued": 0,
"dead": 0
} }
} }
``` ```
...@@ -22,7 +22,7 @@ future GitLab releases.** ...@@ -22,7 +22,7 @@ future GitLab releases.**
## Variables reference ## Variables reference
| Variable | GitLab | Runner | Description | | Variable | GitLab | Runner | Description |
|-------------------------------------------|--------|--------|-------------| |-----------------------------------------------|--------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ARTIFACT_DOWNLOAD_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to download artifacts running a job | | `ARTIFACT_DOWNLOAD_ATTEMPTS` | 8.15 | 1.9 | Number of attempts to download artifacts running a job |
| `CHAT_CHANNEL` | 10.6 | all | Source chat channel which triggered the [ChatOps](../chatops/README.md) command | | `CHAT_CHANNEL` | 10.6 | all | Source chat channel which triggered the [ChatOps](../chatops/README.md) command |
| `CHAT_INPUT` | 10.6 | all | Additional arguments passed in the [ChatOps](../chatops/README.md) command | | `CHAT_INPUT` | 10.6 | all | Additional arguments passed in the [ChatOps](../chatops/README.md) command |
...@@ -44,8 +44,8 @@ future GitLab releases.** ...@@ -44,8 +44,8 @@ future GitLab releases.**
| `CI_CONFIG_PATH` | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | | `CI_CONFIG_PATH` | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` |
| `CI_DEBUG_TRACE` | all | 1.7 | Whether [debug logging (tracing)](README.md#debug-logging) is enabled | | `CI_DEBUG_TRACE` | all | 1.7 | Whether [debug logging (tracing)](README.md#debug-logging) is enabled |
| `CI_DEFAULT_BRANCH` | 12.4 | all | The name of the default branch for the project. | | `CI_DEFAULT_BRANCH` | 12.4 | all | The name of the default branch for the project. |
| `CI_DEPLOY_PASSWORD` | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | `CI_DEPLOY_PASSWORD` | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related. |
| `CI_DEPLOY_USER` | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | `CI_DEPLOY_USER` | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related. |
| `CI_DISPOSABLE_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | | `CI_DISPOSABLE_ENVIRONMENT` | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. |
| `CI_ENVIRONMENT_NAME` | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | | `CI_ENVIRONMENT_NAME` | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. |
| `CI_ENVIRONMENT_SLUG` | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | | `CI_ENVIRONMENT_SLUG` | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. |
......
...@@ -1539,9 +1539,9 @@ cache: ...@@ -1539,9 +1539,9 @@ cache:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5.
If `cache:key:files` is added, the cache `key` will use the SHA of the most recent commit If `cache:key:files` is added, one or two files must be defined with it. The cache `key`
that changed either of the given files. If neither file was changed in any commits, the key will be `default`. will be a SHA computed from the most recent commits (one or two) that changed the
A maximum of two files are allowed. given files. If neither file was changed in any commits, the key will be `default`.
```yaml ```yaml
cache: cache:
...@@ -1559,8 +1559,8 @@ cache: ...@@ -1559,8 +1559,8 @@ cache:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5.
The `prefix` parameter adds extra functionality to `key:files` by allowing the key to The `prefix` parameter adds extra functionality to `key:files` by allowing the key to
be composed of the given `prefix` combined with the SHA of the most recent commit be composed of the given `prefix` combined with the SHA computed for `cache:key:files`.
that changed either of the files. For example, adding a `prefix` of `rspec`, will For example, adding a `prefix` of `rspec`, will
cause keys to look like: `rspec-feef9576d21ee9b6a32e30c5c79d0a0ceb68d1e5`. If neither cause keys to look like: `rspec-feef9576d21ee9b6a32e30c5c79d0a0ceb68d1e5`. If neither
file was changed in any commits, the prefix is added to `default`, so the key in the file was changed in any commits, the prefix is added to `default`, so the key in the
example would be `rspec-default`. example would be `rspec-default`.
......
...@@ -157,3 +157,18 @@ end ...@@ -157,3 +157,18 @@ end
``` ```
will include all rules from `ProjectPolicy`. The delegated conditions will be evaluated with the correct delegated subject, and will be sorted along with the regular rules in the policy. Note that only the relevant rules for a particular ability will actually be considered. will include all rules from `ProjectPolicy`. The delegated conditions will be evaluated with the correct delegated subject, and will be sorted along with the regular rules in the policy. Note that only the relevant rules for a particular ability will actually be considered.
## Specifying Policy Class
You can also override the Policy used for a given subject:
```ruby
class Foo
def self.declarative_policy_class
'SomeOtherPolicy'
end
end
```
This will use & check permissions on the `SomeOtherPolicy` class rather than the usual calculated `FooPolicy` class.
...@@ -69,7 +69,13 @@ before continuing the upgrading procedure. While this won't require downtime ...@@ -69,7 +69,13 @@ before continuing the upgrading procedure. While this won't require downtime
between upgrading major/minor releases, allowing the background migrations to between upgrading major/minor releases, allowing the background migrations to
finish. The time necessary to complete these migrations can be reduced by finish. The time necessary to complete these migrations can be reduced by
increasing the number of Sidekiq workers that can process jobs in the increasing the number of Sidekiq workers that can process jobs in the
`background_migration` queue. `background_migration` queue. To check the size of this queue,
[start a Rails console session](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session)
and run the command below:
```ruby
Sidekiq::Queue.new('background_migration').size
```
As a rule of thumb, any database smaller than 10 GB won't take too much time to As a rule of thumb, any database smaller than 10 GB won't take too much time to
upgrade; perhaps an hour at most per minor release. Larger databases however may upgrade; perhaps an hour at most per minor release. Larger databases however may
......
...@@ -75,7 +75,7 @@ Learn how to embed [GitLab hosted metric charts](../project/integrations/prometh ...@@ -75,7 +75,7 @@ Learn how to embed [GitLab hosted metric charts](../project/integrations/prometh
### Grafana metrics ### Grafana metrics
Learn how to embed [Grafana hosted metric charts](../project/integrations/prometheus.md#embedding-live-grafana-charts). Learn how to embed [Grafana hosted metric charts](../project/integrations/prometheus.md#embedding-grafana-charts).
## Slack integration ## Slack integration
......
...@@ -121,6 +121,7 @@ The following table depicts the various user permission levels in a project. ...@@ -121,6 +121,7 @@ The following table depicts the various user permission levels in a project.
| Manage GitLab Pages domains and certificates | | | | ✓ | ✓ | | Manage GitLab Pages domains and certificates | | | | ✓ | ✓ |
| Remove GitLab Pages | | | | ✓ | ✓ | | Remove GitLab Pages | | | | ✓ | ✓ |
| Manage clusters | | | | ✓ | ✓ | | Manage clusters | | | | ✓ | ✓ |
| View Pods logs **(ULTIMATE)** | | | | ✓ | ✓ |
| Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ | | Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ |
| Edit comments (posted by any user) | | | | ✓ | ✓ | | Edit comments (posted by any user) | | | | ✓ | ✓ |
| Manage Error Tracking | | | | ✓ | ✓ | | Manage Error Tracking | | | | ✓ | ✓ |
......
...@@ -11,7 +11,31 @@ Everything you need to build, test, deploy, and run your app at scale. ...@@ -11,7 +11,31 @@ Everything you need to build, test, deploy, and run your app at scale.
## Overview ## Overview
[Kubernetes](https://kubernetes.io) pod logs can be viewed directly within GitLab. Logs can be displayed by clicking on a specific pod from [Deploy Boards](../deploy_boards.md): [Kubernetes](https://kubernetes.io) pod logs can be viewed directly within GitLab.
![Pod logs](img/kubernetes_pod_logs_v12_5.png)
## Requirements
[Deploying to a Kubernetes environment](../deploy_boards.md#enabling-deploy-boards) is required in order to be able to use Pod Logs.
## Usage
To access pod logs, you must have the right [permissions](../../permissions.md#project-members-permissions).
You can access them in two ways.
### From the project sidebar
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 12.5.
Go to **Operations > Pod logs** on the sidebar menu.
![Sidebar menu](img/sidebar_menu_pod_logs_v12_5.png)
### From Deploy Boards
Logs can be displayed by clicking on a specific pod from [Deploy Boards](../deploy_boards.md):
1. Go to **Operations > Environments** and find the environment which contains the desired pod, like `production`. 1. Go to **Operations > Environments** and find the environment which contains the desired pod, like `production`.
1. On the **Environments** page, you should see the status of the environment's pods with [Deploy Boards](../deploy_boards.md). 1. On the **Environments** page, you should see the status of the environment's pods with [Deploy Boards](../deploy_boards.md).
...@@ -23,9 +47,3 @@ Everything you need to build, test, deploy, and run your app at scale. ...@@ -23,9 +47,3 @@ Everything you need to build, test, deploy, and run your app at scale.
- [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments. - [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments.
Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/6502). Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/6502).
![Deploy Boards pod list](img/kubernetes_pod_logs_v12_4.png)
## Requirements
[Enabling Deploy Boards](../deploy_boards.md#enabling-deploy-boards) is required in order to be able to use Pod Logs.
...@@ -357,6 +357,13 @@ Note the following properties: ...@@ -357,6 +357,13 @@ Note the following properties:
![heatmap panel type](img/heatmap_panel_type.png) ![heatmap panel type](img/heatmap_panel_type.png)
### View and edit the source file of a custom dashboard
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34779) in GitLab 12.5.
When viewing a custom dashboard of a project, you can view the original
`.yml` file by clicking on **Edit dashboard** button.
### Downloading data as CSV ### Downloading data as CSV
Data from Prometheus charts on the metrics dashboard can be downloaded as CSV. Data from Prometheus charts on the metrics dashboard can be downloaded as CSV.
...@@ -465,6 +472,8 @@ Prometheus server. ...@@ -465,6 +472,8 @@ Prometheus server.
## Embedding metric charts within GitLab Flavored Markdown ## Embedding metric charts within GitLab Flavored Markdown
### Embedding GitLab-managed Kubernetes metrics
> [Introduced][ce-29691] in GitLab 12.2. > [Introduced][ce-29691] in GitLab 12.2.
It is possible to display metrics charts within [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm). It is possible to display metrics charts within [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm).
...@@ -492,13 +501,17 @@ The following requirements must be met for the metric to unfurl: ...@@ -492,13 +501,17 @@ The following requirements must be met for the metric to unfurl:
### Embedding metrics in issue templates ### Embedding metrics in issue templates
It is also possible to embed either a dashboard or individual metrics in issue templates. The entire dashboard can be embedded as well as individual metrics, separated by either a comma or a space. It is also possible to embed either the default dashboard metrics or individual metrics in issue templates. For charts to render side-by-side, links to the entire metrics dashboard or individual metrics should be separated by either a comma or a space.
![Embedded Metrics in issue templates](img/embed_metrics_issue_template.png) ![Embedded Metrics in issue templates](img/embed_metrics_issue_template.png)
### Embedding live Grafana charts ### Embedding Grafana charts
It is also possible to embed live [Grafana](https://docs.gitlab.com/omnibus/settings/grafana.html) charts within issues, as a [Direct Linked Rendered Image](https://grafana.com/docs/reference/sharing/#direct-link-rendered-image). Grafana metrics can be embedded in [GitLab Flavored Markdown](../../markdown.md).
#### Embedding charts via Grafana Rendered Images
It is possible to embed live [Grafana](https://docs.gitlab.com/omnibus/settings/grafana.html) charts in issues, as a [direct linked rendered image](https://grafana.com/docs/reference/sharing/#direct-link-rendered-image).
The sharing dialog within Grafana provides the link, as highlighted below. The sharing dialog within Grafana provides the link, as highlighted below.
...@@ -517,6 +530,41 @@ This will render like so: ...@@ -517,6 +530,41 @@ This will render like so:
<img src="https://dashboards.gitlab.com/render/d-solo/RZmbBr7mk/gitlab-triage?orgId=1&refresh=30s&var-env=gprd&var-environment=gprd&var-prometheus=prometheus-01-inf-gprd&var-prometheus_app=prometheus-app-01-inf-gprd&var-backend=All&var-type=All&var-stage=main&panelId=1247&width=1000&height=300"/> <img src="https://dashboards.gitlab.com/render/d-solo/RZmbBr7mk/gitlab-triage?orgId=1&refresh=30s&var-env=gprd&var-environment=gprd&var-prometheus=prometheus-01-inf-gprd&var-prometheus_app=prometheus-app-01-inf-gprd&var-backend=All&var-type=All&var-stage=main&panelId=1247&width=1000&height=300"/>
#### Embedding charts via integration with Grafana HTTP API
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/31376) in GitLab 12.5.
Each project can support integration with one Grafana instance. This configuration allows a user to copy a link to a panel in Grafana, then paste it into a GitLab markdown field. The chart will be rendered in the GitLab chart format.
Prerequisites for embedding from a Grafana instance:
1. The datasource must be a Prometheus instance.
1. The datasource must be proxyable, so the HTTP Access setting should be set to `Server`.
![HTTP Proxy Access](img/http_proxy_access_v12_5.png)
##### Setting up the Grafana integration
1. [Generate an Admin-level API Token in Grafana.](https://grafana.com/docs/http_api/auth/#create-api-token)
1. In your GitLab project, navigate to **Settings > Operations > Grafana Authentication**.
1. To enable the integration, check the "Active" checkbox.
1. For "Grafana URL", enter the base URL of the Grafana instance.
1. For "API Token", enter the Admin API Token you just generated.
1. Click **Save Changes**.
##### Generating a link to a chart
1. In Grafana, navigate to the dashboard you wish to embed a panel from.
![Grafana Metric Panel](img/grafana_panel_v12_5.png)
1. In the upper-left corner of the page, select a specific value for each variable required for the queries in the chart.
![Select Query Variables](img/select_query_variables_v12_5.png)
1. In Grafana, click on a panel's title, then click **Share** to open the panel's sharing dialog to the **Link** tab.
1. If your Prometheus queries use Grafana's custom template variables, ensure that "Template variables" and "Current time range" options are toggled to **On**. Of Grafana global template variables, only `$__interval`, `$__from`, and `$__to` are currently supported.
![Grafana Sharing Dialog](img/grafana_sharing_dialog_v12_5.png)
1. Click **Copy** to copy the URL to the clipboard.
1. In GitLab, paste the URL into a markdown field and save. The chart will take a few moments to render.
![GitLab Rendered Grafana Panel](img/rendered_grafana_embed_v12_5.png)
## Troubleshooting ## Troubleshooting
If the "No data found" screen continues to appear, it could be due to: If the "No data found" screen continues to appear, it could be due to:
......
...@@ -36,7 +36,8 @@ module API ...@@ -36,7 +36,8 @@ module API
{ {
processed: stats.processed, processed: stats.processed,
failed: stats.failed, failed: stats.failed,
enqueued: stats.enqueued enqueued: stats.enqueued,
dead: stats.dead_size
} }
end end
end end
......
...@@ -74,7 +74,14 @@ module DeclarativePolicy ...@@ -74,7 +74,14 @@ module DeclarativePolicy
next unless klass.name next unless klass.name
begin begin
policy_class = "#{klass.name}Policy".constantize klass_name =
if subject_class.respond_to?(:declarative_policy_class)
subject_class.declarative_policy_class
else
"#{klass.name}Policy"
end
policy_class = klass_name.constantize
# NOTE: the < operator here tests whether policy_class # NOTE: the < operator here tests whether policy_class
# inherits from Base. We can't use #is_a? because that # inherits from Base. We can't use #is_a? because that
......
...@@ -28,9 +28,9 @@ module Gitlab ...@@ -28,9 +28,9 @@ module Gitlab
def serialize(group, relations_tree) def serialize(group, relations_tree)
group_tree = tree_saver.serialize(group, relations_tree) group_tree = tree_saver.serialize(group, relations_tree)
group.descendants.each do |descendant| group.children.each do |child|
group_tree['descendants'] = [] unless group_tree['descendants'] group_tree['children'] ||= []
group_tree['descendants'] << serialize(descendant, relations_tree) group_tree['children'] << serialize(child, relations_tree)
end end
group_tree group_tree
......
...@@ -4682,6 +4682,9 @@ msgstr "" ...@@ -4682,6 +4682,9 @@ msgstr ""
msgid "Copy" msgid "Copy"
msgstr "" msgstr ""
msgid "Copy %{field}"
msgstr ""
msgid "Copy %{http_label} clone URL" msgid "Copy %{http_label} clone URL"
msgstr "" msgstr ""
...@@ -8634,6 +8637,9 @@ msgstr "" ...@@ -8634,6 +8637,9 @@ msgstr ""
msgid "GroupSAML|Configuration" msgid "GroupSAML|Configuration"
msgstr "" msgstr ""
msgid "GroupSAML|Copy SAML Response XML"
msgstr ""
msgid "GroupSAML|Enable SAML authentication for this group." msgid "GroupSAML|Enable SAML authentication for this group."
msgstr "" msgstr ""
...@@ -8667,6 +8673,18 @@ msgstr "" ...@@ -8667,6 +8673,18 @@ msgstr ""
msgid "GroupSAML|Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"." msgid "GroupSAML|Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
msgstr "" msgstr ""
msgid "GroupSAML|NameID"
msgstr ""
msgid "GroupSAML|NameID Format"
msgstr ""
msgid "GroupSAML|SAML Response Output"
msgstr ""
msgid "GroupSAML|SAML Response XML"
msgstr ""
msgid "GroupSAML|SAML Single Sign On" msgid "GroupSAML|SAML Single Sign On"
msgstr "" msgstr ""
...@@ -8694,12 +8712,24 @@ msgstr "" ...@@ -8694,12 +8712,24 @@ msgstr ""
msgid "GroupSAML|Toggle SAML authentication" msgid "GroupSAML|Toggle SAML authentication"
msgstr "" msgstr ""
msgid "GroupSAML|Valid SAML Response"
msgstr ""
msgid "GroupSAML|With group managed accounts enabled, all the users without a group managed account will be excluded from the group." msgid "GroupSAML|With group managed accounts enabled, all the users without a group managed account will be excluded from the group."
msgstr "" msgstr ""
msgid "GroupSAML|Your SCIM token" msgid "GroupSAML|Your SCIM token"
msgstr "" msgstr ""
msgid "GroupSAML|must match stored NameID of \"%{extern_uid}\" as we use this to identify users. If the NameID changes users will be unable to sign in."
msgstr ""
msgid "GroupSAML|should be \"persistent\""
msgstr ""
msgid "GroupSAML|should be a random persistent ID, emails are discouraged"
msgstr ""
msgid "GroupSettings|Auto DevOps pipeline was updated for the group" msgid "GroupSettings|Auto DevOps pipeline was updated for the group"
msgstr "" msgstr ""
...@@ -16922,9 +16952,6 @@ msgstr "" ...@@ -16922,9 +16952,6 @@ msgstr ""
msgid "Terms of Service and Privacy Policy" msgid "Terms of Service and Privacy Policy"
msgstr "" msgstr ""
msgid "Test SAML SSO"
msgstr ""
msgid "Test coverage parsing" msgid "Test coverage parsing"
msgstr "" msgstr ""
...@@ -19136,6 +19163,9 @@ msgstr "" ...@@ -19136,6 +19163,9 @@ msgstr ""
msgid "Verified" msgid "Verified"
msgstr "" msgstr ""
msgid "Verify SAML Configuration"
msgstr ""
msgid "Version" msgid "Version"
msgstr "" msgstr ""
......
...@@ -8,7 +8,7 @@ gem 'rake', '~> 12.3.0' ...@@ -8,7 +8,7 @@ gem 'rake', '~> 12.3.0'
gem 'rspec', '~> 3.7' gem 'rspec', '~> 3.7'
gem 'selenium-webdriver', '~> 3.12' gem 'selenium-webdriver', '~> 3.12'
gem 'airborne', '~> 0.2.13' gem 'airborne', '~> 0.2.13'
gem 'nokogiri', '~> 1.10.4' gem 'nokogiri', '~> 1.10.5'
gem 'rspec-retry', '~> 0.6.1' gem 'rspec-retry', '~> 0.6.1'
gem 'rspec_junit_formatter', '~> 0.4.1' gem 'rspec_junit_formatter', '~> 0.4.1'
gem 'faker', '~> 1.6', '>= 1.6.6' gem 'faker', '~> 1.6', '>= 1.6.6'
......
...@@ -55,7 +55,7 @@ GEM ...@@ -55,7 +55,7 @@ GEM
mini_portile2 (2.4.0) mini_portile2 (2.4.0)
minitest (5.11.3) minitest (5.11.3)
netrc (0.11.0) netrc (0.11.0)
nokogiri (1.10.4) nokogiri (1.10.5)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
parallel (1.17.0) parallel (1.17.0)
parallel_tests (2.29.0) parallel_tests (2.29.0)
...@@ -119,7 +119,7 @@ DEPENDENCIES ...@@ -119,7 +119,7 @@ DEPENDENCIES
faker (~> 1.6, >= 1.6.6) faker (~> 1.6, >= 1.6.6)
gitlab-qa gitlab-qa
knapsack (~> 1.17) knapsack (~> 1.17)
nokogiri (~> 1.10.4) nokogiri (~> 1.10.5)
parallel_tests (~> 2.29) parallel_tests (~> 2.29)
pry-byebug (~> 3.5.1) pry-byebug (~> 3.5.1)
rake (~> 12.3.0) rake (~> 12.3.0)
......
...@@ -90,16 +90,18 @@ describe ApplicationController do ...@@ -90,16 +90,18 @@ describe ApplicationController do
let(:format) { :html } let(:format) { :html }
it_behaves_like 'setting gon variables' it_behaves_like 'setting gon variables'
end
context 'with json format' do context 'for peek requests' do
let(:format) { :json } before do
request.path = '/-/peek'
end
it_behaves_like 'not setting gon variables' it_behaves_like 'not setting gon variables'
end end
end
context 'with atom format' do context 'with json format' do
let(:format) { :atom } let(:format) { :json }
it_behaves_like 'not setting gon variables' it_behaves_like 'not setting gon variables'
end end
......
...@@ -228,10 +228,10 @@ describe UploadsController do ...@@ -228,10 +228,10 @@ describe UploadsController do
user.block user.block
end end
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "user", mounted_as: "avatar", id: user.id, filename: "dk.png" } get :show, params: { model: "user", mounted_as: "avatar", id: user.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
...@@ -320,10 +320,10 @@ describe UploadsController do ...@@ -320,10 +320,10 @@ describe UploadsController do
end end
context "when not signed in" do context "when not signed in" do
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" } get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
...@@ -343,10 +343,10 @@ describe UploadsController do ...@@ -343,10 +343,10 @@ describe UploadsController do
project.add_maintainer(user) project.add_maintainer(user)
end end
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" } get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
...@@ -439,10 +439,10 @@ describe UploadsController do ...@@ -439,10 +439,10 @@ describe UploadsController do
user.block user.block
end end
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "group", mounted_as: "avatar", id: group.id, filename: "dk.png" } get :show, params: { model: "group", mounted_as: "avatar", id: group.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
...@@ -526,10 +526,10 @@ describe UploadsController do ...@@ -526,10 +526,10 @@ describe UploadsController do
end end
context "when not signed in" do context "when not signed in" do
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" } get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
...@@ -549,10 +549,10 @@ describe UploadsController do ...@@ -549,10 +549,10 @@ describe UploadsController do
project.add_maintainer(user) project.add_maintainer(user)
end end
it "responds with status 401" do it "redirects to the sign in page" do
get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" } get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" }
expect(response).to have_gitlab_http_status(401) expect(response).to redirect_to(new_user_session_path)
end end
end end
......
...@@ -33,7 +33,6 @@ describe "User toggles subscription", :js do ...@@ -33,7 +33,6 @@ describe "User toggles subscription", :js do
it 'is disabled' do it 'is disabled' do
expect(page).to have_content('Notifications have been disabled by the project or group owner') expect(page).to have_content('Notifications have been disabled by the project or group owner')
expect(page).to have_selector('.js-emails-disabled', visible: true)
expect(page).not_to have_selector('.js-issuable-subscribe-button') expect(page).not_to have_selector('.js-issuable-subscribe-button')
end end
end end
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_emails_disabled": { "type": "boolean" },
"subscribe_disabled_description": { "type": "string" },
"subscribed": { "type": "boolean" }, "subscribed": { "type": "boolean" },
"time_estimate": { "type": "integer" }, "time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" }, "total_time_spent": { "type": "integer" },
......
...@@ -196,6 +196,13 @@ describe('Issuable component', () => { ...@@ -196,6 +196,13 @@ describe('Issuable component', () => {
`${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`, `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`,
); );
}); });
it('renders milestone with the correct href', () => {
const { title } = issuable.milestone;
const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL);
expect(findMilestone().attributes('href')).toBe(expected);
});
}); });
describe.each` describe.each`
......
...@@ -328,3 +328,138 @@ export const metricsGroupsAPIResponse = [ ...@@ -328,3 +328,138 @@ export const metricsGroupsAPIResponse = [
], ],
}, },
]; ];
export const environmentData = [
{
id: 34,
name: 'production',
state: 'available',
external_url: 'http://root-autodevops-deploy.my-fake-domain.com',
environment_type: null,
stop_action: false,
metrics_path: '/root/hello-prometheus/environments/34/metrics',
environment_path: '/root/hello-prometheus/environments/34',
stop_path: '/root/hello-prometheus/environments/34/stop',
terminal_path: '/root/hello-prometheus/environments/34/terminal',
folder_path: '/root/hello-prometheus/environments/folders/production',
created_at: '2018-06-29T16:53:38.301Z',
updated_at: '2018-06-29T16:57:09.825Z',
last_deployment: {
id: 127,
},
},
{
id: 35,
name: 'review/noop-branch',
state: 'available',
external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com',
environment_type: 'review',
stop_action: true,
metrics_path: '/root/hello-prometheus/environments/35/metrics',
environment_path: '/root/hello-prometheus/environments/35',
stop_path: '/root/hello-prometheus/environments/35/stop',
terminal_path: '/root/hello-prometheus/environments/35/terminal',
folder_path: '/root/hello-prometheus/environments/folders/review',
created_at: '2018-07-03T18:39:41.702Z',
updated_at: '2018-07-03T18:44:54.010Z',
last_deployment: {
id: 128,
},
},
{
id: 36,
name: 'no-deployment/noop-branch',
state: 'available',
created_at: '2018-07-04T18:39:41.702Z',
updated_at: '2018-07-04T18:44:54.010Z',
},
];
export const metricsDashboardResponse = {
dashboard: {
dashboard: 'Environment metrics',
priority: 1,
panel_groups: [
{
group: 'System metrics (Kubernetes)',
priority: 5,
panels: [
{
title: 'Memory Usage (Total)',
type: 'area-chart',
y_label: 'Total Memory Used',
weight: 4,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_total',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024',
label: 'Total',
unit: 'GB',
metric_id: 12,
prometheus_endpoint_path: 'http://test',
},
],
},
{
title: 'Core Usage (Total)',
type: 'area-chart',
y_label: 'Total Cores',
weight: 3,
metrics: [
{
id: 'system_metrics_kubernetes_container_cores_total',
query_range:
'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
label: 'Total',
unit: 'cores',
metric_id: 13,
},
],
},
{
title: 'Memory Usage (Pod average)',
type: 'line-chart',
y_label: 'Memory Used per Pod',
weight: 2,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_average',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
label: 'Pod average',
unit: 'MB',
metric_id: 14,
},
],
},
],
},
],
},
status: 'success',
};
export const dashboardGitResponse = [
{
default: true,
display_name: 'Default',
can_edit: false,
project_blob_path: null,
path: 'config/prometheus/common_metrics.yml',
},
{
default: false,
display_name: 'Custom Dashboard 1',
can_edit: true,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`,
path: '.gitlab/dashboards/dashboard_1.yml',
},
{
default: false,
display_name: 'Custom Dashboard 2',
can_edit: true,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`,
path: '.gitlab/dashboards/dashboard_2.yml',
},
];
...@@ -11,81 +11,62 @@ import { uniqMetricsId } from '~/monitoring/stores/utils'; ...@@ -11,81 +11,62 @@ import { uniqMetricsId } from '~/monitoring/stores/utils';
describe('Monitoring mutations', () => { describe('Monitoring mutations', () => {
let stateCopy; let stateCopy;
beforeEach(() => { beforeEach(() => {
stateCopy = state(); stateCopy = state();
}); });
describe('RECEIVE_METRICS_DATA_SUCCESS', () => {
describe(types.RECEIVE_METRICS_DATA_SUCCESS, () => {
let groups; let groups;
beforeEach(() => { beforeEach(() => {
stateCopy.dashboard.panel_groups = []; stateCopy.dashboard.panel_groups = [];
groups = metricsGroupsAPIResponse; groups = metricsGroupsAPIResponse;
}); });
it('adds a key to the group', () => { it('adds a key to the group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0'); expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0');
}); });
it('normalizes values', () => { it('normalizes values', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
const expectedLabel = 'Pod average'; const expectedLabel = 'Pod average';
const { label, query_range } = stateCopy.dashboard.panel_groups[0].metrics[0].metrics[0]; const { label, query_range } = stateCopy.dashboard.panel_groups[0].metrics[0].metrics[0];
expect(label).toEqual(expectedLabel); expect(label).toEqual(expectedLabel);
expect(query_range.length).toBeGreaterThan(0); expect(query_range.length).toBeGreaterThan(0);
}); });
it('contains one group, which it has two panels and one metrics property', () => { it('contains one group, which it has two panels and one metrics property', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups).toBeDefined(); expect(stateCopy.dashboard.panel_groups).toBeDefined();
expect(stateCopy.dashboard.panel_groups.length).toEqual(1); expect(stateCopy.dashboard.panel_groups.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2); expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1); expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1); expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1);
}); });
it('assigns queries a metric id', () => { it('assigns queries a metric id', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries[0].metricId).toEqual( expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries[0].metricId).toEqual(
'17_system_metrics_kubernetes_container_memory_average', '17_system_metrics_kubernetes_container_memory_average',
); );
}); });
describe('dashboard endpoint', () => { describe('dashboard endpoint', () => {
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
it('aliases group panels to metrics for backwards compatibility', () => { it('aliases group panels to metrics for backwards compatibility', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
expect(stateCopy.dashboard.panel_groups[0].metrics[0]).toBeDefined(); expect(stateCopy.dashboard.panel_groups[0].metrics[0]).toBeDefined();
}); });
it('aliases panel metrics to queries for backwards compatibility', () => { it('aliases panel metrics to queries for backwards compatibility', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries).toBeDefined(); expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries).toBeDefined();
}); });
}); });
}); });
describe(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, () => { describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => {
it('stores the deployment data', () => { it('stores the deployment data', () => {
stateCopy.deploymentData = []; stateCopy.deploymentData = [];
mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData); mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData);
expect(stateCopy.deploymentData).toBeDefined(); expect(stateCopy.deploymentData).toBeDefined();
expect(stateCopy.deploymentData.length).toEqual(3); expect(stateCopy.deploymentData.length).toEqual(3);
expect(typeof stateCopy.deploymentData[0]).toEqual('object'); expect(typeof stateCopy.deploymentData[0]).toEqual('object');
}); });
}); });
describe('SET_ENDPOINTS', () => { describe('SET_ENDPOINTS', () => {
it('should set all the endpoints', () => { it('should set all the endpoints', () => {
mutations[types.SET_ENDPOINTS](stateCopy, { mutations[types.SET_ENDPOINTS](stateCopy, {
...@@ -95,7 +76,6 @@ describe('Monitoring mutations', () => { ...@@ -95,7 +76,6 @@ describe('Monitoring mutations', () => {
dashboardEndpoint: 'dashboard.json', dashboardEndpoint: 'dashboard.json',
projectPath: '/gitlab-org/gitlab-foss', projectPath: '/gitlab-org/gitlab-foss',
}); });
expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json'); expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json');
expect(stateCopy.environmentsEndpoint).toEqual('environments.json'); expect(stateCopy.environmentsEndpoint).toEqual('environments.json');
expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json');
...@@ -103,46 +83,44 @@ describe('Monitoring mutations', () => { ...@@ -103,46 +83,44 @@ describe('Monitoring mutations', () => {
expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss'); expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss');
}); });
}); });
describe('SET_QUERY_RESULT', () => { describe('SET_QUERY_RESULT', () => {
const metricId = 12; const metricId = 12;
const id = 'system_metrics_kubernetes_container_memory_total'; const id = 'system_metrics_kubernetes_container_memory_total';
const result = [{ values: [[0, 1], [1, 1], [1, 3]] }]; const result = [
{
values: [[0, 1], [1, 1], [1, 3]],
},
];
beforeEach(() => { beforeEach(() => {
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
}); });
it('clears empty state', () => { it('clears empty state', () => {
mutations[types.SET_QUERY_RESULT](stateCopy, { mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId, metricId,
result, result,
}); });
expect(stateCopy.showEmptyState).toBe(false); expect(stateCopy.showEmptyState).toBe(false);
}); });
it('sets metricsWithData value', () => { it('sets metricsWithData value', () => {
const uniqId = uniqMetricsId({ metric_id: metricId, id }); const uniqId = uniqMetricsId({
metric_id: metricId,
id,
});
mutations[types.SET_QUERY_RESULT](stateCopy, { mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId: uniqId, metricId: uniqId,
result, result,
}); });
expect(stateCopy.metricsWithData).toEqual([uniqId]); expect(stateCopy.metricsWithData).toEqual([uniqId]);
}); });
it('does not store empty results', () => { it('does not store empty results', () => {
mutations[types.SET_QUERY_RESULT](stateCopy, { mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId, metricId,
result: [], result: [],
}); });
expect(stateCopy.metricsWithData).toEqual([]); expect(stateCopy.metricsWithData).toEqual([]);
}); });
}); });
describe('SET_ALL_DASHBOARDS', () => { describe('SET_ALL_DASHBOARDS', () => {
it('stores `undefined` dashboards as an empty array', () => { it('stores `undefined` dashboards as an empty array', () => {
mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined);
...@@ -158,7 +136,6 @@ describe('Monitoring mutations', () => { ...@@ -158,7 +136,6 @@ describe('Monitoring mutations', () => {
it('stores dashboards loaded from the git repository', () => { it('stores dashboards loaded from the git repository', () => {
mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse);
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
}); });
}); });
......
import { import {
anomalyMockGraphData as importedAnomalyMockGraphData, anomalyMockGraphData as importedAnomalyMockGraphData,
deploymentData as importedDeploymentData,
metricsNewGroupsAPIResponse as importedMetricsNewGroupsAPIResponse,
metricsGroupsAPIResponse as importedMetricsGroupsAPIResponse, metricsGroupsAPIResponse as importedMetricsGroupsAPIResponse,
environmentData as importedEnvironmentData,
dashboardGitResponse as importedDashboardGitResponse,
} from '../../frontend/monitoring/mock_data'; } from '../../frontend/monitoring/mock_data';
// TODO Check if these exports are still needed
export const anomalyMockGraphData = importedAnomalyMockGraphData; export const anomalyMockGraphData = importedAnomalyMockGraphData;
export const deploymentData = importedDeploymentData;
export const metricsNewGroupsAPIResponse = importedMetricsNewGroupsAPIResponse;
export const metricsGroupsAPIResponse = importedMetricsGroupsAPIResponse; export const metricsGroupsAPIResponse = importedMetricsGroupsAPIResponse;
export const environmentData = importedEnvironmentData;
export const dashboardGitResponse = importedDashboardGitResponse;
export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`; export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`;
export const mockProjectPath = '/frontend-fixtures/environments-project';
export const mockedQueryResultPayload = { export const mockedQueryResultPayload = {
metricId: '17_system_metrics_kubernetes_container_memory_average', metricId: '17_system_metrics_kubernetes_container_memory_average',
result: [ result: [
...@@ -101,141 +98,6 @@ export const mockedQueryResultPayloadCoresTotal = { ...@@ -101,141 +98,6 @@ export const mockedQueryResultPayloadCoresTotal = {
], ],
}; };
export const environmentData = [
{
id: 34,
name: 'production',
state: 'available',
external_url: 'http://root-autodevops-deploy.my-fake-domain.com',
environment_type: null,
stop_action: false,
metrics_path: '/root/hello-prometheus/environments/34/metrics',
environment_path: '/root/hello-prometheus/environments/34',
stop_path: '/root/hello-prometheus/environments/34/stop',
terminal_path: '/root/hello-prometheus/environments/34/terminal',
folder_path: '/root/hello-prometheus/environments/folders/production',
created_at: '2018-06-29T16:53:38.301Z',
updated_at: '2018-06-29T16:57:09.825Z',
last_deployment: {
id: 127,
},
},
{
id: 35,
name: 'review/noop-branch',
state: 'available',
external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com',
environment_type: 'review',
stop_action: true,
metrics_path: '/root/hello-prometheus/environments/35/metrics',
environment_path: '/root/hello-prometheus/environments/35',
stop_path: '/root/hello-prometheus/environments/35/stop',
terminal_path: '/root/hello-prometheus/environments/35/terminal',
folder_path: '/root/hello-prometheus/environments/folders/review',
created_at: '2018-07-03T18:39:41.702Z',
updated_at: '2018-07-03T18:44:54.010Z',
last_deployment: {
id: 128,
},
},
{
id: 36,
name: 'no-deployment/noop-branch',
state: 'available',
created_at: '2018-07-04T18:39:41.702Z',
updated_at: '2018-07-04T18:44:54.010Z',
},
];
export const metricsDashboardResponse = {
dashboard: {
dashboard: 'Environment metrics',
priority: 1,
panel_groups: [
{
group: 'System metrics (Kubernetes)',
priority: 5,
panels: [
{
title: 'Memory Usage (Total)',
type: 'area-chart',
y_label: 'Total Memory Used',
weight: 4,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_total',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024',
label: 'Total',
unit: 'GB',
metric_id: 12,
prometheus_endpoint_path: 'http://test',
},
],
},
{
title: 'Core Usage (Total)',
type: 'area-chart',
y_label: 'Total Cores',
weight: 3,
metrics: [
{
id: 'system_metrics_kubernetes_container_cores_total',
query_range:
'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
label: 'Total',
unit: 'cores',
metric_id: 13,
},
],
},
{
title: 'Memory Usage (Pod average)',
type: 'line-chart',
y_label: 'Memory Used per Pod',
weight: 2,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_average',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
label: 'Pod average',
unit: 'MB',
metric_id: 14,
},
],
},
],
},
],
},
status: 'success',
};
export const dashboardGitResponse = [
{
default: true,
display_name: 'Default',
can_edit: false,
project_blob_path: null,
path: 'config/prometheus/common_metrics.yml',
},
{
default: false,
display_name: 'Custom Dashboard 1',
can_edit: true,
project_blob_path: `${mockProjectPath}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`,
path: '.gitlab/dashboards/dashboard_1.yml',
},
{
default: false,
display_name: 'Custom Dashboard 2',
can_edit: true,
project_blob_path: `${mockProjectPath}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`,
path: '.gitlab/dashboards/dashboard_2.yml',
},
];
export const graphDataPrometheusQuery = { export const graphDataPrometheusQuery = {
title: 'Super Chart A2', title: 'Super Chart A2',
type: 'single-stat', type: 'single-stat',
......
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import {
fetchDashboard,
receiveMetricsDashboardSuccess,
receiveMetricsDashboardFailure,
fetchDeploymentsData,
fetchEnvironmentsData,
fetchPrometheusMetrics,
fetchPrometheusMetric,
requestMetricsData,
setEndpoints,
setGettingStartedEmptyState,
} from '~/monitoring/stores/actions';
import storeState from '~/monitoring/stores/state';
import testAction from 'spec/helpers/vuex_action_helper';
import { resetStore } from '../helpers';
import {
deploymentData,
environmentData,
metricsDashboardResponse,
metricsGroupsAPIResponse,
dashboardGitResponse,
} from '../mock_data';
describe('Monitoring store actions', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
resetStore(store);
mock.restore();
});
describe('requestMetricsData', () => {
it('sets emptyState to loading', () => {
const commit = jasmine.createSpy();
const { state } = store;
requestMetricsData({ state, commit });
expect(commit).toHaveBeenCalledWith(types.REQUEST_METRICS_DATA);
});
});
describe('fetchDeploymentsData', () => {
it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => {
const dispatch = jasmine.createSpy();
const { state } = store;
state.deploymentsEndpoint = '/success';
mock.onGet(state.deploymentsEndpoint).reply(200, {
deployments: deploymentData,
});
fetchDeploymentsData({ state, dispatch })
.then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataSuccess', deploymentData);
done();
})
.catch(done.fail);
});
it('commits RECEIVE_DEPLOYMENTS_DATA_FAILURE on error', done => {
const dispatch = jasmine.createSpy();
const { state } = store;
state.deploymentsEndpoint = '/error';
mock.onGet(state.deploymentsEndpoint).reply(500);
fetchDeploymentsData({ state, dispatch })
.then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataFailure');
done();
})
.catch(done.fail);
});
});
describe('fetchEnvironmentsData', () => {
it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on error', done => {
const dispatch = jasmine.createSpy();
const { state } = store;
state.environmentsEndpoint = '/success';
mock.onGet(state.environmentsEndpoint).reply(200, {
environments: environmentData,
});
fetchEnvironmentsData({ state, dispatch })
.then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataSuccess', environmentData);
done();
})
.catch(done.fail);
});
it('commits RECEIVE_ENVIRONMENTS_DATA_FAILURE on error', done => {
const dispatch = jasmine.createSpy();
const { state } = store;
state.environmentsEndpoint = '/error';
mock.onGet(state.environmentsEndpoint).reply(500);
fetchEnvironmentsData({ state, dispatch })
.then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataFailure');
done();
})
.catch(done.fail);
});
});
describe('Set endpoints', () => {
let mockedState;
beforeEach(() => {
mockedState = storeState();
});
it('should commit SET_ENDPOINTS mutation', done => {
testAction(
setEndpoints,
{
metricsEndpoint: 'additional_metrics.json',
deploymentsEndpoint: 'deployments.json',
environmentsEndpoint: 'deployments.json',
},
mockedState,
[
{
type: types.SET_ENDPOINTS,
payload: {
metricsEndpoint: 'additional_metrics.json',
deploymentsEndpoint: 'deployments.json',
environmentsEndpoint: 'deployments.json',
},
},
],
[],
done,
);
});
});
describe('Set empty states', () => {
let mockedState;
beforeEach(() => {
mockedState = storeState();
});
it('should commit SET_METRICS_ENDPOINT mutation', done => {
testAction(
setGettingStartedEmptyState,
null,
mockedState,
[{ type: types.SET_GETTING_STARTED_EMPTY_STATE }],
[],
done,
);
});
});
describe('fetchDashboard', () => {
let dispatch;
let state;
const response = metricsDashboardResponse;
beforeEach(() => {
dispatch = jasmine.createSpy();
state = storeState();
state.dashboardEndpoint = '/dashboard';
});
it('dispatches receive and success actions', done => {
const params = {};
mock.onGet(state.dashboardEndpoint).reply(200, response);
fetchDashboard({ state, dispatch }, params)
.then(() => {
expect(dispatch).toHaveBeenCalledWith('requestMetricsDashboard');
expect(dispatch).toHaveBeenCalledWith('receiveMetricsDashboardSuccess', {
response,
params,
});
done();
})
.catch(done.fail);
});
it('dispatches failure action', done => {
const params = {};
mock.onGet(state.dashboardEndpoint).reply(500);
fetchDashboard({ state, dispatch }, params)
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
done();
})
.catch(done.fail);
});
});
describe('receiveMetricsDashboardSuccess', () => {
let commit;
let dispatch;
let state;
beforeEach(() => {
commit = jasmine.createSpy();
dispatch = jasmine.createSpy();
state = storeState();
});
it('stores groups ', () => {
const params = {};
const response = metricsDashboardResponse;
receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response, params });
expect(commit).toHaveBeenCalledWith(
types.RECEIVE_METRICS_DATA_SUCCESS,
metricsDashboardResponse.dashboard.panel_groups,
);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params);
});
it('sets the dashboards loaded from the repository', () => {
const params = {};
const response = metricsDashboardResponse;
response.all_dashboards = dashboardGitResponse;
receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response, params });
expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse);
});
});
describe('receiveMetricsDashboardFailure', () => {
let commit;
beforeEach(() => {
commit = jasmine.createSpy();
});
it('commits failure action', () => {
receiveMetricsDashboardFailure({ commit });
expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, undefined);
});
it('commits failure action with error', () => {
receiveMetricsDashboardFailure({ commit }, 'uh-oh');
expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, 'uh-oh');
});
});
describe('fetchPrometheusMetrics', () => {
let commit;
let dispatch;
beforeEach(() => {
commit = jasmine.createSpy();
dispatch = jasmine.createSpy();
});
it('commits empty state when state.groups is empty', done => {
const state = storeState();
const params = {};
fetchPrometheusMetrics({ state, commit, dispatch }, params)
.then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE);
expect(dispatch).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('dispatches fetchPrometheusMetric for each panel query', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
fetchPrometheusMetrics({ state, commit, dispatch }, params)
.then(() => {
expect(dispatch.calls.count()).toEqual(3);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, params });
done();
})
.catch(done.fail);
done();
});
});
describe('fetchPrometheusMetric', () => {
it('commits prometheus query result', done => {
const commit = jasmine.createSpy();
const params = {
start: '2019-08-06T12:40:02.184Z',
end: '2019-08-06T20:40:02.184Z',
};
const metric = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics[0];
const state = storeState();
const data = metricsGroupsAPIResponse[0].panels[0].metrics[0];
const response = { data };
mock.onGet('http://test').reply(200, response);
fetchPrometheusMetric({ state, commit }, { metric, params });
setTimeout(() => {
expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, {
metricId: metric.metric_id,
result: data.result,
});
done();
});
});
});
});
...@@ -76,4 +76,25 @@ describe('Subscriptions', function() { ...@@ -76,4 +76,25 @@ describe('Subscriptions', function() {
expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
}); });
describe('given project emails are disabled', () => {
const subscribeDisabledDescription = 'Notifications have been disabled';
beforeEach(() => {
vm = mountComponent(Subscriptions, {
subscribed: false,
projectEmailsDisabled: true,
subscribeDisabledDescription,
});
});
it('sets the correct display text', () => {
expect(vm.$el.textContent).toContain(subscribeDisabledDescription);
expect(vm.$refs.tooltip.dataset.originalTitle).toBe(subscribeDisabledDescription);
});
it('does not render the toggle button', () => {
expect(vm.$refs.toggleButton).toBeUndefined();
});
});
}); });
...@@ -30,6 +30,8 @@ issues: ...@@ -30,6 +30,8 @@ issues:
- prometheus_alert_events - prometheus_alert_events
- self_managed_prometheus_alert_events - self_managed_prometheus_alert_events
- zoom_meetings - zoom_meetings
- vulnerability_links
- related_vulnerabilities
events: events:
- author - author
- project - project
......
...@@ -39,12 +39,16 @@ describe Gitlab::ImportExport::GroupTreeSaver do ...@@ -39,12 +39,16 @@ describe Gitlab::ImportExport::GroupTreeSaver do
end end
context 'when :export_fast_serialize feature is enabled' do context 'when :export_fast_serialize feature is enabled' do
let(:serializer) { instance_double(Gitlab::ImportExport::FastHashSerializer) }
before do before do
stub_feature_flags(export_fast_serialize: true) stub_feature_flags(export_fast_serialize: true)
expect(Gitlab::ImportExport::FastHashSerializer).to receive(:new).with(group, group_tree).and_return(serializer)
end end
it 'uses FastHashSerializer' do it 'uses FastHashSerializer' do
expect_any_instance_of(Gitlab::ImportExport::FastHashSerializer).to receive(:execute).and_call_original expect(serializer).to receive(:execute)
group_tree_saver.save group_tree_saver.save
end end
...@@ -103,6 +107,18 @@ describe Gitlab::ImportExport::GroupTreeSaver do ...@@ -103,6 +107,18 @@ describe Gitlab::ImportExport::GroupTreeSaver do
expect(saved_group_json['badges']).not_to be_empty expect(saved_group_json['badges']).not_to be_empty
end end
context 'group children' do
let(:children) { group.children }
it 'exports group children' do
expect(saved_group_json['children'].length).to eq(children.count)
end
it 'exports group children of children' do
expect(saved_group_json['children'].first['children'].length).to eq(children.first.children.count)
end
end
context 'group members' do context 'group members' do
let(:user2) { create(:user, email: 'group@member.com') } let(:user2) { create(:user, email: 'group@member.com') }
let(:member_emails) do let(:member_emails) do
...@@ -146,6 +162,8 @@ describe Gitlab::ImportExport::GroupTreeSaver do ...@@ -146,6 +162,8 @@ describe Gitlab::ImportExport::GroupTreeSaver do
def setup_group def setup_group
group = create(:group, description: 'description') group = create(:group, description: 'description')
sub_group = create(:group, description: 'description', parent: group)
create(:group, description: 'description', parent: sub_group)
create(:milestone, group: group) create(:milestone, group: group)
create(:group_badge, group: group) create(:group_badge, group: group)
group_label = create(:group_label, group: group) group_label = create(:group_label, group: group)
......
...@@ -744,6 +744,12 @@ describe Environment, :use_clean_rails_memory_store_caching do ...@@ -744,6 +744,12 @@ describe Environment, :use_clean_rails_memory_store_caching do
allow(environment).to receive(:deployment_platform).and_return(double) allow(environment).to receive(:deployment_platform).and_return(double)
end end
context 'reactive cache configuration' do
it 'does not continue to spawn jobs' do
expect(described_class.reactive_cache_lifetime).to be < described_class.reactive_cache_refresh_interval
end
end
context 'reactive cache is empty' do context 'reactive cache is empty' do
before do before do
stub_reactive_cache(environment, nil) stub_reactive_cache(environment, nil)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Loading a user avatar' do
let(:user) { create(:user, :with_avatar) }
context 'when logged in' do
# The exact query count will vary depending on the 2FA settings of the
# instance, group, and user. Removing those extra 2FA queries in this case
# may not be a good idea, so we just set up the ideal case.
before do
stub_application_setting(require_two_factor_authentication: true)
login_as(create(:user, :two_factor))
end
# One each for: current user, avatar user, and upload record
it 'only performs three SQL queries' do
get user.avatar_url # Skip queries on first application load
expect(response).to have_gitlab_http_status(200)
expect { get user.avatar_url }.not_to exceed_query_limit(3)
end
end
context 'when logged out' do
# One each for avatar user and upload record
it 'only performs two SQL queries' do
get user.avatar_url # Skip queries on first application load
expect(response).to have_gitlab_http_status(200)
expect { get user.avatar_url }.not_to exceed_query_limit(2)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe IssuableSidebarExtrasEntity do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:resource) { create(:issue, project: project) }
let(:request) { double('request', current_user: user) }
subject { described_class.new(resource, request: request).as_json }
it 'have subscribe attributes' do
expect(subject).to include(:participants,
:project_emails_disabled,
:subscribe_disabled_description,
:subscribed,
:assignees)
end
end
...@@ -24,33 +24,63 @@ describe MergeRequests::FfMergeService do ...@@ -24,33 +24,63 @@ describe MergeRequests::FfMergeService do
context 'valid params' do context 'valid params' do
let(:service) { described_class.new(project, user, valid_merge_params) } let(:service) { described_class.new(project, user, valid_merge_params) }
before do def execute_ff_merge
allow(service).to receive(:execute_hooks)
perform_enqueued_jobs do perform_enqueued_jobs do
service.execute(merge_request) service.execute(merge_request)
end end
end end
before do
allow(service).to receive(:execute_hooks)
end
it "does not create merge commit" do it "does not create merge commit" do
execute_ff_merge
source_branch_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha source_branch_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha
target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha
expect(source_branch_sha).to eq(target_branch_sha) expect(source_branch_sha).to eq(target_branch_sha)
end end
it { expect(merge_request).to be_valid } it 'keeps the merge request valid' do
it { expect(merge_request).to be_merged } expect { execute_ff_merge }
.not_to change { merge_request.valid? }
end
it 'updates the merge request to merged' do
expect { execute_ff_merge }
.to change { merge_request.merged? }
.from(false)
.to(true)
end
it 'sends email to user2 about merge of new merge_request' do it 'sends email to user2 about merge of new merge_request' do
execute_ff_merge
email = ActionMailer::Base.deliveries.last email = ActionMailer::Base.deliveries.last
expect(email.to.first).to eq(user2.email) expect(email.to.first).to eq(user2.email)
expect(email.subject).to include(merge_request.title) expect(email.subject).to include(merge_request.title)
end end
it 'creates system note about merge_request merge' do it 'creates system note about merge_request merge' do
execute_ff_merge
note = merge_request.notes.last note = merge_request.notes.last
expect(note.note).to include 'merged' expect(note.note).to include 'merged'
end end
it 'does not update squash_commit_sha if it is not a squash' do
expect { execute_ff_merge }.not_to change { merge_request.squash_commit_sha }
end
it 'updates squash_commit_sha if it is a squash' do
merge_request.update!(squash: true)
expect { execute_ff_merge }
.to change { merge_request.squash_commit_sha }
.from(nil)
end
end end
context 'error handling' do context 'error handling' do
...@@ -83,6 +113,16 @@ describe MergeRequests::FfMergeService do ...@@ -83,6 +113,16 @@ describe MergeRequests::FfMergeService do
expect(merge_request.merge_error).to include(error_message) expect(merge_request.merge_error).to include(error_message)
expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message))
end end
it 'does not update squash_commit_sha if squash merge is not successful' do
merge_request.update!(squash: true)
expect(project.repository.raw).to receive(:ff_merge) do
raise 'Merge error'
end
expect { service.execute(merge_request) }.not_to change { merge_request.squash_commit_sha }
end
end end
end end
end end
...@@ -66,6 +66,11 @@ RSpec.configure do |config| ...@@ -66,6 +66,11 @@ RSpec.configure do |config|
config.infer_spec_type_from_file_location! config.infer_spec_type_from_file_location!
config.full_backtrace = !!ENV['CI'] config.full_backtrace = !!ENV['CI']
unless ENV['CI']
# Re-run failures locally with `--only-failures`
config.example_status_persistence_file_path = './spec/examples.txt'
end
config.define_derived_metadata(file_path: %r{(ee)?/spec/.+_spec\.rb\z}) do |metadata| config.define_derived_metadata(file_path: %r{(ee)?/spec/.+_spec\.rb\z}) do |metadata|
location = metadata[:location] location = metadata[:location]
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment