Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-16 18:08:46 +00:00
parent d47f9d2304
commit aa0f0e9921
125 changed files with 3429 additions and 736 deletions

View File

@ -17,10 +17,13 @@ import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import DateTimePicker from './date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
import { metricStates } from '../constants';
@ -31,16 +34,18 @@ export default {
components: {
VueDraggable,
PanelType,
GraphGroup,
EmptyState,
GroupEmptyState,
Icon,
GlButton,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlModal,
DateTimePicker,
GraphGroup,
EmptyState,
GroupEmptyState,
DashboardsDropdown,
},
directives: {
GlModal: GlModalDirective,
@ -83,6 +88,10 @@ export default {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
metricsEndpoint: {
type: String,
required: true,
@ -140,6 +149,11 @@ export default {
required: false,
default: invalidUrl,
},
dashboardsEndpoint: {
type: String,
required: false,
default: invalidUrl,
},
currentDashboard: {
type: String,
required: false,
@ -199,9 +213,6 @@ export default {
selectedDashboard() {
return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
},
selectedDashboardText() {
return this.selectedDashboard.display_name;
},
showRearrangePanelsBtn() {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
@ -223,6 +234,7 @@ export default {
environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentsEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
dashboardsEndpoint: this.dashboardsEndpoint,
currentDashboard: this.currentDashboard,
projectPath: this.projectPath,
});
@ -314,6 +326,13 @@ export default {
return !this.getMetricStates(groupKey).includes(metricStates.OK);
},
getAddMetricTrackingOptions,
selectDashboard(dashboard) {
const params = {
dashboard: dashboard.path,
};
redirectTo(mergeUrlParams(params, window.location.href));
},
},
addMetric: {
title: s__('Metrics|Add metric'),
@ -333,21 +352,14 @@ export default {
label-for="monitor-dashboards-dropdown"
class="col-sm-12 col-md-6 col-lg-2"
>
<gl-dropdown
<dashboards-dropdown
id="monitor-dashboards-dropdown"
class="mb-0 d-flex js-dashboards-dropdown"
class="mb-0 d-flex"
toggle-class="dropdown-menu-toggle"
:text="selectedDashboardText"
>
<gl-dropdown-item
v-for="dashboard in allDashboards"
:key="dashboard.path"
:active="dashboard.path === currentDashboard"
active-class="is-active"
:href="`?dashboard=${dashboard.path}`"
>{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item
>
</gl-dropdown>
:default-branch="defaultBranch"
:selected-dashboard="selectedDashboard"
@selectDashboard="selectDashboard($event)"
/>
</gl-form-group>
<gl-form-group

View File

@ -0,0 +1,139 @@
<script>
import { mapState, mapActions } from 'vuex';
import {
GlAlert,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
GlLoadingIcon,
GlModalDirective,
} from '@gitlab/ui';
import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
const events = {
selectDashboard: 'selectDashboard',
};
export default {
components: {
GlAlert,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
GlLoadingIcon,
DuplicateDashboardForm,
},
directives: {
GlModal: GlModalDirective,
},
props: {
selectedDashboard: {
type: Object,
required: false,
default: () => ({}),
},
defaultBranch: {
type: String,
required: true,
},
},
data() {
return {
alert: null,
loading: false,
form: {},
};
},
computed: {
...mapState('monitoringDashboard', ['allDashboards']),
isSystemDashboard() {
return this.selectedDashboard.system_dashboard;
},
selectedDashboardText() {
return this.selectedDashboard.display_name;
},
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
selectDashboard(dashboard) {
this.$emit(events.selectDashboard, dashboard);
},
ok(bvModalEvt) {
// Prevent modal from hiding in case submit fails
bvModalEvt.preventDefault();
this.loading = true;
this.alert = null;
this.duplicateSystemDashboard(this.form)
.then(createdDashboard => {
this.loading = false;
this.alert = null;
// Trigger hide modal as submit is successful
this.$refs.duplicateDashboardModal.hide();
// Dashboards in the default branch become available immediately.
// Not so in other branches, so we refresh the current dashboard
const dashboard =
this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
this.$emit(events.selectDashboard, dashboard);
})
.catch(error => {
this.loading = false;
this.alert = error;
});
},
hide() {
this.alert = null;
},
formChange(form) {
this.form = form;
},
},
};
</script>
<template>
<gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText">
<gl-dropdown-item
v-for="dashboard in allDashboards"
:key="dashboard.path"
:active="dashboard.path === selectedDashboard.path"
active-class="is-active"
@click="selectDashboard(dashboard)"
>
{{ dashboard.display_name || dashboard.path }}
</gl-dropdown-item>
<template v-if="isSystemDashboard">
<gl-dropdown-divider />
<gl-modal
ref="duplicateDashboardModal"
modal-id="duplicateDashboardModal"
:title="s__('Metrics|Duplicate dashboard')"
ok-variant="success"
@ok="ok"
@hide="hide"
>
<gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
{{ alert }}
</gl-alert>
<duplicate-dashboard-form
:dashboard="selectedDashboard"
:default-branch="defaultBranch"
@change="formChange"
/>
<template #modal-ok>
<gl-loading-icon v-if="loading" inline color="light" />
{{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }}
</template>
</gl-modal>
<gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
{{ s__('Metrics|Duplicate dashboard') }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>

View File

@ -0,0 +1,138 @@
<script>
import { __, s__, sprintf } from '~/locale';
import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui';
const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0];
export default {
components: {
GlFormGroup,
GlFormInput,
GlFormRadioGroup,
GlFormTextarea,
},
props: {
dashboard: {
type: Object,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
},
radioVals: {
/* Use the default branch (e.g. master) */
DEFAULT: 'DEFAULT',
/* Create a new branch */
NEW: 'NEW',
},
data() {
return {
form: {
dashboard: this.dashboard.path,
fileName: defaultFileName(this.dashboard),
commitMessage: '',
},
branchName: '',
branchOption: this.$options.radioVals.NEW,
branchOptions: [
{
value: this.$options.radioVals.DEFAULT,
html: sprintf(
__('Commit to %{branchName} branch'),
{
branchName: `<strong>${this.defaultBranch}</strong>`,
},
false,
),
},
{ value: this.$options.radioVals.NEW, text: __('Create new branch') },
],
};
},
computed: {
defaultCommitMsg() {
return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), {
fileName: this.form.fileName,
});
},
fileNameState() {
// valid if empty or *.yml
return !(this.form.fileName && !this.form.fileName.endsWith('.yml'));
},
fileNameFeedback() {
return !this.fileNameState ? s__('The file name should have a .yml extension') : '';
},
},
mounted() {
this.change();
},
methods: {
change() {
this.$emit('change', {
...this.form,
commitMessage: this.form.commitMessage || this.defaultCommitMsg,
branch:
this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch,
});
},
focus(option) {
if (option === this.$options.radioVals.NEW) {
this.$nextTick(() => {
this.$refs.branchName.$el.focus();
});
}
},
},
};
</script>
<template>
<form @change="change">
<p class="text-muted">
{{
s__(`Metrics|You can save a copy of this dashboard to your repository
so it can be customized. Select a file name and branch to
save it.`)
}}
</p>
<gl-form-group
ref="fileNameFormGroup"
:label="__('File name')"
:state="fileNameState"
:invalid-feedback="fileNameFeedback"
label-size="sm"
label-for="fileName"
>
<gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" />
</gl-form-group>
<gl-form-group :label="__('Branch')" label-size="sm" label-for="branch">
<gl-form-radio-group
ref="branchOption"
v-model="branchOption"
:checked="$options.radioVals.NEW"
:stacked="true"
:options="branchOptions"
@change="focus"
/>
<gl-form-input
v-show="branchOption === $options.radioVals.NEW"
id="branchName"
ref="branchName"
v-model="branchName"
/>
</gl-form-group>
<gl-form-group
:label="__('Commit message (optional)')"
label-size="sm"
label-for="commitMessage"
>
<gl-form-textarea
id="commitMessage"
ref="commitMessage"
v-model="form.commitMessage"
:placeholder="defaultCommitMsg"
/>
</gl-form-group>
</form>
</template>

View File

@ -214,5 +214,29 @@ export const setPanelGroupMetrics = ({ commit }, data) => {
commit(types.SET_PANEL_GROUP_METRICS, data);
};
export const duplicateSystemDashboard = ({ state }, payload) => {
const params = {
dashboard: payload.dashboard,
file_name: payload.fileName,
branch: payload.branch,
commit_message: payload.commitMessage,
};
return axios
.post(state.dashboardsEndpoint, params)
.then(response => response.data)
.then(data => data.dashboard)
.catch(error => {
const { response } = error;
if (response && response.data && response.data.error) {
throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), {
error: response.data.error,
});
} else {
throw s__('Metrics|There was an error creating the dashboard.');
}
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -175,6 +175,7 @@ export default {
state.environmentsEndpoint = endpoints.environmentsEndpoint;
state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
state.dashboardEndpoint = endpoints.dashboardEndpoint;
state.dashboardsEndpoint = endpoints.dashboardsEndpoint;
state.currentDashboard = endpoints.currentDashboard;
state.projectPath = endpoints.projectPath;
},

View File

@ -1,5 +1,5 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import DeploymentInfo from './deployment_info.vue';
import DeploymentViewButton from './deployment_view_button.vue';
import DeploymentStopButton from './deployment_stop_button.vue';
@ -14,9 +14,6 @@ export default {
DeploymentStopButton,
DeploymentViewButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
deployment: {
type: Object,
@ -43,6 +40,14 @@ export default {
},
},
computed: {
appButtonText() {
return {
text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'),
tooltip: this.isCurrent
? ''
: __('View the latest successful deployment to this environment'),
};
},
canBeManuallyDeployed() {
return this.computedDeploymentStatus === MANUAL_DEPLOY;
},
@ -55,9 +60,6 @@ export default {
hasExternalUrls() {
return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
hasPreviousDeployment() {
return Boolean(!this.isCurrent && this.deployment.deployed_at);
},
isCurrent() {
return this.computedDeploymentStatus === SUCCESS;
},
@ -89,7 +91,7 @@ export default {
<!-- show appropriate version of review app button -->
<deployment-view-button
v-if="hasExternalUrls"
:is-current="isCurrent"
:app-button-text="appButtonText"
:deployment="deployment"
:show-visual-review-app="showVisualReviewApp"
:visual-review-app-metadata="visualReviewAppMeta"

View File

@ -11,12 +11,12 @@ export default {
import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
},
props: {
deployment: {
appButtonText: {
type: Object,
required: true,
},
isCurrent: {
type: Boolean,
deployment: {
type: Object,
required: true,
},
showVisualReviewApp: {
@ -60,7 +60,7 @@ export default {
>
<template slot="mainAction" slot-scope="slotProps">
<review-app-link
:is-current="isCurrent"
:display="appButtonText"
:link="deploymentExternalUrl"
:css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
/>
@ -85,7 +85,7 @@ export default {
</filtered-search-dropdown>
<template v-else>
<review-app-link
:is-current="isCurrent"
:display="appButtonText"
:link="deploymentExternalUrl"
css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/>

View File

@ -1,18 +1,21 @@
<script>
import { __ } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
cssClass: {
type: String,
required: true,
},
isCurrent: {
type: Boolean,
display: {
type: Object,
required: true,
},
link: {
@ -20,15 +23,12 @@ export default {
required: true,
},
},
computed: {
linkText() {
return this.isCurrent ? __('View app') : __('View previous app');
},
},
};
</script>
<template>
<a
v-gl-tooltip
:title="display.tooltip"
:href="link"
target="_blank"
rel="noopener noreferrer nofollow"
@ -36,6 +36,6 @@ export default {
data-track-event="open_review_app"
data-track-label="review_app"
>
{{ linkText }} <icon class="fgray" name="external-link" />
{{ display.text }} <icon class="fgray" name="external-link" />
</a>
</template>

View File

@ -7,90 +7,53 @@ module Projects
before_action :check_repository_available!
before_action :validate_required_params!
before_action :validate_dashboard_template!
before_action :authorize_push!
USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT
DASHBOARD_TEMPLATES = {
::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH
}.freeze
rescue_from ActionController::ParameterMissing do |exception|
respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param })
end
def create
result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute
result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute
if result[:status] == :success
respond_success
respond_success(result)
else
respond_error(result[:message])
respond_error(result)
end
end
private
def respond_success
def respond_success(result)
set_web_ide_link_notice(result.dig(:dashboard, :path))
respond_to do |format|
format.html { redirect_to ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path) }
format.json { render json: { redirect_to: ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path) }, status: :created }
format.json { render status: result.delete(:http_status), json: result }
end
end
def respond_error(message)
flash[:alert] = message
def respond_error(result)
respond_to do |format|
format.html { redirect_back_or_default(default: namespace_project_environments_path) }
format.json { render json: { error: message }, status: :bad_request }
format.json { render json: { error: result[:message] }, status: result[:http_status] }
end
end
def authorize_push!
access_denied!(%q(You can't commit to this project)) unless user_access(project).can_push_to_branch?(params[:branch])
def set_web_ide_link_notice(new_dashboard_path)
web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">"
message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" }
flash[:notice] = message.html_safe
end
def validate_required_params!
params.require(%i(branch file_name dashboard))
end
def validate_dashboard_template!
access_denied! unless dashboard_template
end
def dashboard_attrs
{
commit_message: commit_message,
file_path: new_dashboard_path,
file_content: new_dashboard_content,
encoding: 'text',
branch_name: params[:branch],
start_branch: repository.branch_exists?(params[:branch]) ? params[:branch] : project.default_branch
}
end
def commit_message
params[:commit_message] || "Create custom dashboard #{params[:file_name]}"
end
def new_dashboard_path
File.join(USER_DASHBOARDS_DIR, params[:file_name])
end
def new_dashboard_content
File.read(Rails.root.join(dashboard_template))
end
def dashboard_template
dashboard_templates[params[:dashboard]]
end
def dashboard_templates
DASHBOARD_TEMPLATES
params.require(%i(branch file_name dashboard commit_message))
end
def redirect_safe_branch_name
repository.find_branch(params[:branch]).name
end
def dashboard_params
params.permit(%i(branch file_name dashboard commit_message)).to_h
end
end
end
end
Projects::PerformanceMonitoring::DashboardsController.prepend_if_ee('EE::Projects::PerformanceMonitoring::DashboardsController')

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Resolvers
class EnvironmentsResolver < BaseResolver
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'Name of the environment'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query'
type Types::EnvironmentType, null: true
alias_method :project, :object
def resolve(**args)
return unless project.present?
EnvironmentsFinder.new(project, context[:current_user], args).find
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Types
class EnvironmentType < BaseObject
graphql_name 'Environment'
description 'Describes where code is deployed for a project'
authorize :read_environment
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the environment'
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the environment'
end
end

View File

@ -138,6 +138,12 @@ module Types
description: 'Issues of the project',
resolver: Resolvers::IssuesResolver
field :environments,
Types::EnvironmentType.connection_type,
null: true,
description: 'Environments of the project',
resolver: Resolvers::EnvironmentsResolver
field :issue,
Types::IssueType,
null: true,

View File

@ -29,8 +29,10 @@ module EnvironmentsHelper
"empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
"dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json),
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
"deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json),
"default-branch" => project.default_branch,
"environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),

View File

@ -197,6 +197,10 @@ module Ci
AutoMergeProcessWorker.perform_async(merge_request.id)
end
if pipeline.auto_devops_source?
self.class.auto_devops_pipelines_completed_total.increment(status: pipeline.status)
end
end
end
@ -330,6 +334,10 @@ module Ci
::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending]
end
def self.auto_devops_pipelines_completed_total
@auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines')
end
def stages_count
statuses.select(:stage).distinct.count
end

View File

@ -307,6 +307,8 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
scope :without_ghosts, -> { where('ghost IS NOT TRUE') }
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
@ -470,7 +472,7 @@ class User < ApplicationRecord
when 'deactivated'
deactivated
else
active
active_without_ghosts
end
end
@ -614,7 +616,7 @@ class User < ApplicationRecord
end
def self.non_internal
where('ghost IS NOT TRUE')
without_ghosts
end
#

View File

@ -0,0 +1,113 @@
# frozen_string_literal: true
# Copies system dashboard definition in .yml file into designated
# .yml file inside `.gitlab/dashboards`
module Metrics
module Dashboard
class CloneDashboardService < ::BaseService
ALLOWED_FILE_TYPE = '.yml'
USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT
def self.allowed_dashboard_templates
@allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze
end
def execute
catch(:error) do
throw(:error, error(_(%q(You can't commit to this project)), :forbidden)) unless push_authorized?
result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute
throw(:error, wrap_error(result)) unless result[:status] == :success
repository.refresh_method_caches([:metrics_dashboard])
success(result.merge(http_status: :created, dashboard: dashboard_details))
end
end
private
def dashboard_attrs
{
commit_message: params[:commit_message],
file_path: new_dashboard_path,
file_content: new_dashboard_content,
encoding: 'text',
branch_name: branch,
start_branch: repository.branch_exists?(branch) ? branch : project.default_branch
}
end
def dashboard_details
{
path: new_dashboard_path,
display_name: ::Metrics::Dashboard::ProjectDashboardService.name_for_path(new_dashboard_path),
default: false,
system_dashboard: false
}
end
def push_authorized?
Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch)
end
def dashboard_template
@dashboard_template ||= begin
throw(:error, error(_('Not found.'), :not_found)) unless self.class.allowed_dashboard_templates.include?(params[:dashboard])
params[:dashboard]
end
end
def branch
@branch ||= begin
throw(:error, error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request)) unless valid_branch_name?
throw(:error, error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request)) unless new_or_default_branch? # temporary validation for first UI iteration
params[:branch]
end
end
def new_or_default_branch?
!repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch]
end
def valid_branch_name?
Gitlab::GitRefValidator.validate(params[:branch])
end
def new_dashboard_path
@new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name)
end
def file_name
@file_name ||= begin
throw(:error, error(_('The file name should have a .yml extension'), :bad_request)) unless target_file_type_valid?
File.basename(params[:file_name])
end
end
def target_file_type_valid?
File.extname(params[:file_name]) == ALLOWED_FILE_TYPE
end
def new_dashboard_content
File.read(Rails.root.join(dashboard_template))
end
def repository
@repository ||= project.repository
end
def wrap_error(result)
if result[:message] == 'A file with this name already exists'
error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request)
else
result
end
end
end
end
end
Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService')

View File

@ -9,7 +9,7 @@
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
%small.badge.badge-pill= limited_counter_with_delimiter(User.active)
%small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')

View File

@ -44,8 +44,10 @@
= expanded ? _('Collapse') : _('Expand')
%p
- auto_devops_url = help_page_path('topics/autodevops/index')
- quickstart_url = help_page_path('topics/autodevops/quick_start_guide')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
= s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
- quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url }
= s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
.settings-content
= render 'groups/settings/ci_cd/auto_devops_form', group: @group

View File

@ -44,7 +44,7 @@
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
= link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
= link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
%span
= _('Contribution Analytics')

View File

@ -23,8 +23,11 @@
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.')
= link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md')
- auto_devops_url = help_page_path('topics/autodevops/index')
- quickstart_url = help_page_path('topics/autodevops/quick_start_guide')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
- quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url }
= s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
.settings-content
= render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled?

View File

@ -0,0 +1,5 @@
---
title: Update button label in MR widget pipeline footer
merge_request: 22900
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Adds quickstart doc link to ADO CICD settings
merge_request:
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add ability to duplicate the common metrics dashboard
merge_request: 21929
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Get Project's environment names via GraphQL
merge_request: 22932
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Return empty body for 204 responses in API
merge_request: 22086
author:
type: fixed

View File

@ -38,9 +38,13 @@ rescue StandardError => e
warn "There was a problem trying to check the Changelog. Exception: #{e.name} - #{e.message}"
end
def sanitized_mr_title
helper.sanitize_mr_title(gitlab.mr_json["title"])
end
if git.modified_files.include?("CHANGELOG.md")
fail "**CHANGELOG.md was edited.** Please remove the additions and create a CHANGELOG entry.\n\n" +
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: changelog.sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
end
changelog_found = changelog.found
@ -50,6 +54,6 @@ if changelog.needed?
check_changelog(changelog_found)
else
message "**[CHANGELOG missing](https://docs.gitlab.com/ce/development/changelog.html)**: If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.\n\n" +
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: changelog.sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
end
end

View File

@ -1,295 +1,162 @@
# frozen_string_literal: true
require 'json'
require_relative File.expand_path('../../lib/gitlab/danger/commit_linter', __dir__)
URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50"
URL_GIT_COMMIT = "https://chris.beams.io/posts/git-commit/"
# rubocop: disable Style/SignalException
# rubocop: disable Metrics/CyclomaticComplexity
# rubocop: disable Metrics/PerceivedComplexity
# Perform various checks against commits. We're not using
# https://github.com/jonallured/danger-commit_lint because its output is not
# very helpful, and it doesn't offer the means of ignoring merge commits.
class EmojiChecker
DIGESTS = File.expand_path('../../fixtures/emojis/digests.json', __dir__)
ALIASES = File.expand_path('../../fixtures/emojis/aliases.json', __dir__)
# A regex that indicates a piece of text _might_ include an Emoji. The regex
# alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this
# regex to save us from having to check for all possible emoji names when we
# know one definitely is not included.
LIKELY_EMOJI = /:[\+a-z0-9_\-]+:/.freeze
def initialize
names = JSON.parse(File.read(DIGESTS)).keys +
JSON.parse(File.read(ALIASES)).keys
@emoji = names.map { |name| ":#{name}:" }
end
def includes_emoji?(text)
return false unless text.match?(LIKELY_EMOJI)
@emoji.any? { |emoji| text.include?(emoji) }
end
end
MAX_COMMITS_COUNT = 10
def gitlab_danger
@gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper)
end
def fail_commit(commit, message)
fail("#{commit.sha}: #{message}")
self.fail("#{commit.sha}: #{message}")
end
def warn_commit(commit, message)
warn("#{commit.sha}: #{message}")
self.warn("#{commit.sha}: #{message}")
end
def lines_changed_in_commit(commit)
commit.diff_parent.stats[:total][:lines]
def squash_mr?
gitlab_danger.ci? ? gitlab.mr_json['squash'] : false
end
def subject_starts_with_capital?(subject)
first_char = subject.chars.first
first_char.upcase == first_char
def wip_mr?
gitlab_danger.ci? ? gitlab.mr_json['work_in_progress'] : false
end
def too_many_changed_lines?(commit)
commit.diff_parent.stats[:total][:files] > 3 &&
lines_changed_in_commit(commit) >= 30
end
# Perform various checks against commits. We're not using
# https://github.com/jonallured/danger-commit_lint because its output is not
# very helpful, and it doesn't offer the means of ignoring merge commits.
def lint_commit(commit)
linter = Gitlab::Danger::CommitLinter.new(commit)
def emoji_checker
@emoji_checker ||= EmojiChecker.new
end
def unicode_emoji_regex
@unicode_emoji_regex ||= %r((
[\u{1F300}-\u{1F5FF}] |
[\u{1F1E6}-\u{1F1FF}] |
[\u{2700}-\u{27BF}] |
[\u{1F900}-\u{1F9FF}] |
[\u{1F600}-\u{1F64F}] |
[\u{1F680}-\u{1F6FF}] |
[\u{2600}-\u{26FF}]
))x
end
def count_filtered_commits(commits)
commits.count do |commit|
!commit.message.start_with?('fixup!', 'squash!')
end
end
def lint_commit(commit) # rubocop:disable Metrics/AbcSize
# For now we'll ignore merge commits, as getting rid of those is a problem
# separate from enforcing good commit messages.
return false if commit.message.start_with?('Merge branch')
return linter if linter.merge?
# We ignore revert commits as they are well structured by Git already
return false if commit.message.start_with?('Revert "')
return linter if linter.revert?
is_squash = gitlab_danger.ci? ? gitlab.mr_json['squash'] : false
is_wip = gitlab_danger.ci? ? gitlab.mr_json['work_in_progress'] : false
is_fixup = commit.message.start_with?('fixup!', 'squash!')
# If MR is set to squash, we ignore fixup commits
return linter if linter.fixup? && squash_mr?
if is_fixup
# The MR is set to squash - Danger adds an informative notice
# The MR is not set to squash - Danger fails. if also WIP warn only, not error
if is_squash
return false
end
if is_wip
warn_commit(
commit,
'Squash or Fixup commits must be squashed before merge, or enable squash merge option'
)
if linter.fixup?
msg = 'Squash or fixup commits must be squashed before merge, or enable squash merge option'
if wip_mr? || squash_mr?
warn_commit(commit, msg)
else
fail_commit(
commit,
'Squash or Fixup commits must be squashed before merge, or enable squash merge option'
)
fail_commit(commit, msg)
end
# Makes no sense to process other rules for fixup commits, they trigger just more noise
return false
return linter
end
# Fail if a suggestion commit is used and squash is not enabled
if commit.message.start_with?('Apply suggestion to')
if is_squash
return false
else
fail_commit(
commit,
'If you are applying suggestions, enable squash in the merge request and re-run the failed job'
)
return true
if linter.suggestion?
unless squash_mr?
fail_commit(commit, "If you are applying suggestions, enable squash in the merge request and re-run the `danger-review` job")
end
return linter
end
failures = false
subject, separator, details = commit.message.split("\n", 3)
linter.lint
end
if subject.split.length < 3
fail_commit(
commit,
'The commit subject must contain at least three words'
)
def lint_mr_title(mr_title)
commit = Struct.new(:message, :sha).new(mr_title)
failures = true
end
Gitlab::Danger::CommitLinter.new(commit).lint_subject("merge request title")
end
if subject.length > 72
fail_commit(
commit,
'The commit subject may not be longer than 72 characters'
)
failures = true
elsif subject.length > 50
warn_commit(
commit,
"This commit's subject line is acceptable, but please try to [reduce it to 50 characters](#{URL_LIMIT_SUBJECT})."
)
end
unless subject_starts_with_capital?(subject)
fail_commit(commit, 'The commit subject must start with a capital letter')
failures = true
end
if subject.end_with?('.')
fail_commit(commit, 'The commit subject must not end with a period')
failures = true
end
if separator && !separator.empty?
fail_commit(
commit,
'The commit subject and body must be separated by a blank line'
)
failures = true
end
details&.each_line do |line|
line = line.strip
next if line.length <= 72
url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length }
# If the line includes a URL, we'll allow it to exceed 72 characters, but
# only if the line _without_ the URL does not exceed this limit.
next if line.length - url_size <= 72
fail_commit(
commit,
'The commit body should not contain more than 72 characters per line'
)
failures = true
end
if !details && too_many_changed_lines?(commit)
fail_commit(
commit,
'Commits that change 30 or more lines across at least three files ' \
'must describe these changes in the commit body'
)
failures = true
end
if emoji_checker.includes_emoji?(commit.message)
warn_commit(
commit,
'Avoid the use of Markdown Emoji such as `:+1:`. ' \
'These add limited value to the commit message, ' \
'and are displayed as plain text outside of GitLab'
)
failures = true
end
if commit.message.match?(unicode_emoji_regex)
fail_commit(
commit,
'Avoid the use of Unicode Emoji. ' \
'These add no value to the commit message, ' \
'and may not be displayed properly everywhere'
)
failures = true
end
if commit.message.match?(%r(([\w\-\/]+)?(#|!|&|%)\d+\b))
fail_commit(
commit,
'Use full URLs instead of short references ' \
'(`gitlab-org/gitlab#123` or `!123`), as short references are ' \
'displayed as plain text outside of GitLab'
)
failures = true
end
failures
def count_non_fixup_commits(commit_linters)
commit_linters.count { |commit_linter| !commit_linter.fixup? }
end
def lint_commits(commits)
failed = commits.select do |commit|
lint_commit(commit)
commit_linters = commits.map { |commit| lint_commit(commit) }
failed_commit_linters = commit_linters.select { |commit_linter| commit_linter.failed? }
warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?)
if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT
level = squash_mr? ? :warn : :fail
self.__send__(level, # rubocop:disable GitlabSecurity/PublicSend
"This merge request includes more than #{MAX_COMMITS_COUNT} commits. " \
'Please rebase these commits into a smaller number of commits or split ' \
'this merge request into multiple smaller merge requests.')
end
if failed.any?
markdown(<<~MARKDOWN)
## Commit message standards
if squash_mr?
multi_line_commit_linter = commit_linters.detect { |commit_linter| commit_linter.multi_line? }
One or more commit messages do not meet our Git commit message standards.
For more information on how to write a good commit message, take a look at
[How to Write a Git Commit Message](#{URL_GIT_COMMIT}).
Here is an example of a good commit message:
Reject ruby interpolation in externalized strings
When using ruby interpolation in externalized strings, they can't be
detected. Which means they will never be presented to be translated.
To mix variables into translations we need to use `sprintf`
instead.
Instead of:
_("Hello \#{subject}")
Use:
_("Hello %{subject}") % { subject: 'world' }
This is an example of a bad commit message:
updated README.md
This commit message is bad because although it tells us that README.md is
updated, it doesn't tell us why or how it was updated.
MARKDOWN
if multi_line_commit_linter && multi_line_commit_linter.lint.failed?
warn_or_fail_commits(multi_line_commit_linter)
fail_message('The commit message that will be used in the squash commit does not meet our Git commit message standards.')
else
title_linter = lint_mr_title(gitlab.mr_json['title'])
if title_linter.failed?
warn_or_fail_commits(title_linter)
fail_message('The merge request title that will be used in the squash commit does not meet our Git commit message standards.')
end
end
else
if failed_commit_linters.any?
fail_message('One or more commit messages do not meet our Git commit message standards.')
end
end
end
def warn_or_fail_commits(failed_linters, default_to_fail: true)
level = default_to_fail ? :fail : :warn
Array(failed_linters).each do |linter|
linter.problems.each do |problem_key, problem_desc|
case problem_key
when :subject_above_warning
warn_commit(linter.commit, problem_desc)
else
self.__send__("#{level}_commit", linter.commit, problem_desc) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
end
def fail_message(intro)
markdown(<<~MARKDOWN)
## Commit message standards
#{intro}
For more information on how to write a good commit message, take a look at
[How to Write a Git Commit Message](#{URL_GIT_COMMIT}).
Here is an example of a good commit message:
Reject ruby interpolation in externalized strings
When using ruby interpolation in externalized strings, they can't be
detected. Which means they will never be presented to be translated.
To mix variables into translations we need to use `sprintf`
instead.
Instead of:
_("Hello \#{subject}")
Use:
_("Hello %{subject}") % { subject: 'world' }
This is an example of a bad commit message:
updated README.md
This commit message is bad because although it tells us that README.md is
updated, it doesn't tell us why or how it was updated.
MARKDOWN
end
lint_commits(git.commits)
if count_filtered_commits(git.commits) > 10
fail(
'This merge request includes more than 10 commits. ' \
'Please rebase these commits into a smaller number of commits.'
)
end

View File

@ -1412,6 +1412,56 @@ enum EntryType {
tree
}
"""
Describes where code is deployed for a project
"""
type Environment {
"""
ID of the environment
"""
id: ID!
"""
Human-readable name of the environment
"""
name: String!
}
"""
The connection type for Environment.
"""
type EnvironmentConnection {
"""
A list of edges.
"""
edges: [EnvironmentEdge]
"""
A list of nodes.
"""
nodes: [Environment]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type EnvironmentEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Environment
}
"""
Represents an epic.
"""
@ -4706,6 +4756,41 @@ type Project {
"""
descriptionHtml: String
"""
Environments of the project
"""
environments(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Name of the environment
"""
name: String
"""
Search query
"""
search: String
): EnvironmentConnection
"""
Number of times the project has been forked
"""

View File

@ -406,6 +406,79 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environments",
"description": "Environments of the project",
"args": [
{
"name": "name",
"description": "Name of the environment",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "search",
"description": "Search query",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EnvironmentConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "forksCount",
"description": "Number of times the project has been forked",
@ -15431,6 +15504,167 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EnvironmentConnection",
"description": "The connection type for Environment.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EnvironmentEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Environment",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EnvironmentEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Environment",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Environment",
"description": "Describes where code is deployed for a project",
"fields": [
{
"name": "id",
"description": "ID of the environment",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Human-readable name of the environment",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryDetailedError",

View File

@ -232,6 +232,15 @@ Autogenerated return type of DestroySnippet
| `replyId` | ID! | ID used to reply to this discussion |
| `createdAt` | Time! | Timestamp of the discussion's creation |
## Environment
Describes where code is deployed for a project
| Name | Type | Description |
| --- | ---- | ---------- |
| `name` | String! | Human-readable name of the environment |
| `id` | ID! | ID of the environment |
## Epic
Represents an epic.

View File

@ -6,7 +6,7 @@ Configuration for approvals on all Merge Requests (MR) in the project. Must be a
### Get Configuration
>**Note:** This API endpoint is only available on 10.6 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6.
You can request information about a project's approval configuration using the
following endpoint:
@ -31,7 +31,7 @@ GET /projects/:id/approvals
### Change configuration
>**Note:** This API endpoint is only available on 10.6 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6.
If you are allowed to, you can change approval configuration using the following
endpoint:
@ -63,7 +63,7 @@ POST /projects/:id/approvals
### Get project-level rules
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can request information about a project's approval rules using the following endpoint:
@ -137,7 +137,7 @@ GET /projects/:id/approval_rules
### Create project-level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can create project approval rules using the following endpoint:
@ -213,7 +213,7 @@ POST /projects/:id/approval_rules
### Update project-level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can update project approval rules using the following endpoint:
@ -292,7 +292,7 @@ PUT /projects/:id/approval_rules/:approval_rule_id
### Delete project-level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can delete project approval rules using the following endpoint:
@ -310,7 +310,7 @@ DELETE /projects/:id/approval_rules/:approval_rule_id
### Change allowed approvers
>**Note:** This API endpoint has been deprecated. Please use Approval Rule API instead.
>**Note:** This API endpoint is only available on 10.6 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6.
If you are allowed to, you can change approvers and approver groups using
the following endpoint:
@ -373,7 +373,7 @@ Configuration for approvals on a specific Merge Request. Must be authenticated f
### Get Configuration
>**Note:** This API endpoint is only available on 8.9 Starter and above.
> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 8.9.
You can request information about a merge request's approval status using the
following endpoint:
@ -419,7 +419,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/approvals
### Change approval configuration
>**Note:** This API endpoint is only available on 10.6 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6.
If you are allowed to, you can change `approvals_required` using the following
endpoint:
@ -456,7 +456,7 @@ POST /projects/:id/merge_requests/:merge_request_iid/approvals
### Change allowed approvers for Merge Request
>**Note:** This API endpoint has been deprecated. Please use Approval Rule API instead.
>**Note:** This API endpoint is only available on 10.6 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6.
If you are allowed to, you can change approvers and approver groups using
the following endpoint:
@ -598,7 +598,7 @@ This includes additional information about the users who have already approved
### Get merge request level rules
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13712) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can request information about a merge request's approval rules using the following endpoint:
@ -674,7 +674,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/approval_rules
### Create merge request level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can create merge request approval rules using the following endpoint:
@ -757,7 +757,7 @@ will be used.
### Update merge request level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can update merge request approval rules using the following endpoint:
@ -841,7 +841,7 @@ These are system generated rules.
### Delete merge request level rule
>**Note:** This API endpoint is only available on 12.3 Starter and above.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3.
You can delete merge request approval rules using the following endpoint:
@ -862,7 +862,7 @@ These are system generated rules.
## Approve Merge Request
>**Note:** This API endpoint is only available on 8.9 Starter and above.
> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 8.9.
If you are allowed to, you can approve a merge request using the following
endpoint:
@ -925,7 +925,7 @@ does not match, the response code will be `409`.
## Unapprove Merge Request
>**Note:** This API endpoint is only available on 9.0 Starter and above.
>Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 9.0.
If you did approve a merge request, you can unapprove it using the following
endpoint:

View File

@ -92,6 +92,12 @@ For instance:
Model.create(foo: params[:foo])
```
## Using HTTP status helpers
For non-200 HTTP responses, use the provided helpers in `lib/api/helpers.rb` to ensure correct behaviour (`not_found!`, `no_content!` etc.). These will `throw` inside Grape and abort the execution of your endpoint.
For `DELETE` requests, you should also generally use the `destroy_conditionally!` helper which by default returns a `204 No Content` response on success, or a `412 Precondition Failed` response if the given `If-Unmodified-Since` header is out of range. This helper calls `#destroy` on the passed resource, but you can also implement a custom deletion method by passing a block.
## Using API path helpers in GitLab Rails codebase
Because we support [installing GitLab under a relative URL], one must take this

View File

@ -941,7 +941,7 @@ a helpful link back to how the feature was developed.
Over time, version text will reference a progressively older version of GitLab. In cases where version text
refers to versions of GitLab four or more major versions back, consider removing the text.
For example, if the current major version is 11.x, version text referencing versions of GitLab 7.x
For example, if the current major version is 12.x, version text referencing versions of GitLab 8.x
and older are candidates for removal.
NOTE: **Note:**

View File

@ -78,3 +78,71 @@ follow up issue and attach it to the component implementation epic found within
If you are using a submit button inside a form and you attach an `onSubmit` event listener on the form element, [this piece of code](https://gitlab.com/gitlab-org/gitlab/blob/794c247a910e2759ce9b401356432a38a4535d49/app/assets/javascripts/main.js#L225) will add a `disabled` class selector to the submit button when the form is submitted.
To avoid this behavior, add the class `js-no-auto-disable` to the button.
### 5. Should I use a full URL (i.e. `gon.gitlab_url`) or a full path (i.e. `gon.relative_url_root`) when referencing backend endpoints?
It's preferred to use a **full path** over a **full URL** because the URL will use the hostname configured with
GitLab which may not match the request. This will cause [CORS issues like this Web IDE one](https://gitlab.com/gitlab-org/gitlab/issues/36810).
Example:
```javascript
// bad :(
// If gitlab is configured with hostname `0.0.0.0`
// This will cause CORS issues if I request from `localhost`
axios.get(joinPaths(gon.gitlab_url, '-', 'foo'))
// good :)
axios.get(joinPaths(gon.relative_url_root, '-', 'foo'))
```
Also, please try not to hardcode paths in the Frontend, but instead receive them from the Backend (see next section).
When referencing Backend rails paths, avoid using `*_url`, and use `*_path` instead.
Example:
```haml
-# Bad :(
#js-foo{ data: { foo_url: some_rails_foo_url } }
-# Good :)
#js-foo{ data: { foo_path: some_rails_foo_path } }
```
### 6. How should the Frontend reference Backend paths?
We prefer not to add extra coupling by hardcoding paths. If possible,
add these paths as data attributes to the DOM element being referenced in the JavaScript.
Example:
```javascript
// Bad :(
// Here's a Vuex action that hardcodes a path :(
export const fetchFoos = ({ state }) => {
return axios.get(joinPaths(gon.relative_url_root, '-', 'foo'));
};
// Good :)
function initFoo() {
const el = document.getElementById('js-foo');
// Path comes from our root element's data which is used to initialize the store :)
const store = createStore({
fooPath: el.dataset.fooPath
});
Vue.extend({
store,
el,
render(h) {
return h(Component);
},
});
}
// Vuex action can now reference the path from it's state :)
export const fetchFoos = ({ state }) => {
return axios.get(state.settings.fooPath);
};
```

View File

@ -433,6 +433,8 @@ Filebeat will run as a DaemonSet on each node in your cluster, and it will ship
GitLab will then connect to Elasticsearch for logs instead of the Kubernetes API,
and you will have access to more advanced querying capabilities.
Log data is automatically deleted after 15 days using [Curator](https://www.elastic.co/guide/en/elasticsearch/client/curator/5.5/about.html).
This is a preliminary release of Elastic Stack as a GitLab-managed application. By default,
the ability to install it is disabled.

View File

@ -155,12 +155,13 @@ NOTE: **Note:**
The custom metrics as defined below do not support alerts, unlike
[additional metrics](#adding-additional-metrics-premium).
Dashboards have several components:
#### Adding a new dashboard to your project
- Panel groups, which comprise panels.
- Panels, which support one or more metrics.
You can configure a custom dashboard by adding a new `.yml` file into a project's repository. Only `.yml` files present in the projects **default** branch are displayed on the project's **Operations > Metrics** section.
To configure a custom dashboard:
You may create a new file from scratch or duplicate a GitLab-defined dashboard.
**Add a `.yml` file manually**
1. Create a YAML file with the `.yml` extension under your repository's root
directory inside `.gitlab/dashboards/`. For example, create
@ -185,7 +186,7 @@ To configure a custom dashboard:
define the layout of the dashboard and the Prometheus queries used to populate
data.
1. Save the file, commit, and push to your repository.
1. Save the file, commit, and push to your repository. The file must be present in your **default** branch.
1. Navigate to your project's **Operations > Metrics** and choose the custom
dashboard from the dropdown.
@ -193,6 +194,28 @@ NOTE: **Note:**
Configuration files nested under subdirectories of `.gitlab/dashboards` are not
supported and will not be available in the UI.
**Duplicate a GitLab-defined dashboard as a new `.yml` file**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/37238) in GitLab 12.7.
You can save a copy of a GitLab defined dashboard that can be customized and adapted to your project. You can decide to save the dashboard new `.yml` file in the project's **default** branch or in a newly created branch with a name of your choosing.
1. Click on the "Duplicate dashboard" in the dashboard dropdown.
NOTE:**Note:**
Only GitLab-defined dashboards can be duplicated.
1. Input the file name and other information, such as a new commit message, and click on "Duplicate".
If you select your **default** branch, the new dashboard will become immediately available. If you select another branch, this branch should be merged to your **default** branch first.
#### Dashboard YAML properties
Dashboards have several components:
- Panel groups, which comprise of panels.
- Panels, which support one or more metrics.
The following tables outline the details of expected properties.
**Dashboard properties:**

View File

@ -41,6 +41,10 @@ CAUTION: **CAUTION:**
From GitLab 12.6 onwards, if the [visibility of an upstream project is reduced](../../../public_access/public_access.md#reducing-visibility)
in any way, the fork relationship with all its forks will be removed.
CAUTION: **Caution:**
[Repository mirroring](repository_mirroring.md) will help to keep your fork synced with the original repository.
Before approving a merge request you'll likely to be asked to sync before getting approval, hence automating it is recommend.
## Merging upstream
Once you are ready to send your code back to the main project, you need

View File

@ -38,7 +38,7 @@ module API
application = ApplicationsFinder.new(params).execute
application.destroy
status 204
no_content!
end
end
end

View File

@ -135,7 +135,6 @@ module API
end
destroy_conditionally!(badge)
body false
end
end
end

View File

@ -57,7 +57,7 @@ module API
requires :branch, type: String, desc: 'The name of the branch'
end
head do
user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404)
user_project.repository.branch_exists?(params[:branch]) ? no_content! : not_found!
end
get do
branch = find_branch!(params[:branch])

View File

@ -77,7 +77,7 @@ module API
resource.custom_attributes.find_by!(key: params[:key]).destroy
status 204
no_content!
end
# rubocop: enable CodeReuse/ActiveRecord
end

View File

@ -74,7 +74,7 @@ module API
delete ':name' do
Feature.get(params[:name]).remove
status 204
no_content!
end
end
end

View File

@ -67,7 +67,7 @@ module API
milestone = user_group.milestones.find(params[:milestone_id])
Milestones::DestroyService.new(user_group, current_user).execute(milestone)
status(204)
no_content!
end
desc 'Get all issues for a single group milestone' do

View File

@ -31,6 +31,7 @@ module API
check_unmodified_since!(last_updated)
status 204
body false
if block_given?
yield resource

View File

@ -17,9 +17,9 @@ module API
delete ':id/pages' do
authorize! :remove_pages, user_project
status 204
::Pages::DeleteService.new(user_project, current_user).execute
no_content!
end
end
end

View File

@ -148,8 +148,9 @@ module API
delete ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do
authorize! :update_pages, user_project
status 204
pages_domain.destroy
no_content!
end
end
end

View File

@ -69,7 +69,7 @@ module API
milestone = user_project.milestones.find(params[:milestone_id])
Milestones::DestroyService.new(user_project, current_user).execute(milestone)
status(204)
no_content!
end
desc 'Get all issues for a single project milestone' do

View File

@ -447,7 +447,7 @@ module API
::Projects::UnlinkForkService.new(user_project, current_user).execute
end
result ? status(204) : not_modified!
not_modified! unless result
end
desc 'Share the project with a group' do

View File

@ -346,8 +346,9 @@ module API
key = user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
status 204
key.destroy
no_content!
end
# rubocop: enable CodeReuse/ActiveRecord
@ -760,8 +761,9 @@ module API
key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
status 204
key.destroy
no_content!
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -111,9 +111,10 @@ module API
variable = user_project.variables.find_by(key: params[:key])
not_found!('Variable') unless variable
# Variables don't have any timestamp. Therfore, destroy unconditionally.
status 204
# Variables don't have a timestamp. Therefore, destroy unconditionally.
variable.destroy
no_content!
end
# rubocop: enable CodeReuse/ActiveRecord
end

View File

@ -107,8 +107,9 @@ module API
delete ':id/wikis/:slug' do
authorize! :admin_wiki, user_project
status 204
WikiPages::DestroyService.new(user_project, current_user).execute(wiki_page)
no_content!
end
desc 'Upload an attachment to the wiki repository' do

View File

@ -0,0 +1,232 @@
# frozen_string_literal: true
emoji_checker_path = File.expand_path('emoji_checker', __dir__)
defined?(Rails) ? require_dependency(emoji_checker_path) : require_relative(emoji_checker_path)
module Gitlab
module Danger
class CommitLinter
MIN_SUBJECT_WORDS_COUNT = 3
MAX_LINE_LENGTH = 72
WARN_SUBJECT_LENGTH = 50
URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50"
MAX_CHANGED_FILES_IN_COMMIT = 3
MAX_CHANGED_LINES_IN_COMMIT = 30
SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(#|!|&|%)\d+\b}.freeze
DEFAULT_SUBJECT_DESCRIPTION = 'commit subject'
PROBLEMS = {
subject_too_short: "The %s must contain at least #{MIN_SUBJECT_WORDS_COUNT} words",
subject_too_long: "The %s may not be longer than #{MAX_LINE_LENGTH} characters",
subject_above_warning: "The %s length is acceptable, but please try to [reduce it to #{WARN_SUBJECT_LENGTH} characters](#{URL_LIMIT_SUBJECT}).",
subject_starts_with_lowercase: "The %s must start with a capital letter",
subject_ends_with_a_period: "The %s must not end with a period",
separator_missing: "The commit subject and body must be separated by a blank line",
details_too_many_changes: "Commits that change #{MAX_CHANGED_LINES_IN_COMMIT} or more lines across " \
"at least #{MAX_CHANGED_FILES_IN_COMMIT} files must describe these changes in the commit body",
details_line_too_long: "The commit body should not contain more than #{MAX_LINE_LENGTH} characters per line",
message_contains_text_emoji: "Avoid the use of Markdown Emoji such as `:+1:`. These add limited value " \
"to the commit message, and are displayed as plain text outside of GitLab",
message_contains_unicode_emoji: "Avoid the use of Unicode Emoji. These add no value to the commit " \
"message, and may not be displayed properly everywhere",
message_contains_short_reference: "Use full URLs instead of short references (`gitlab-org/gitlab#123` or " \
"`!123`), as short references are displayed as plain text outside of GitLab"
}.freeze
attr_reader :commit, :problems
def initialize(commit)
@commit = commit
@problems = {}
@linted = false
end
def fixup?
commit.message.start_with?('fixup!', 'squash!')
end
def suggestion?
commit.message.start_with?('Apply suggestion to')
end
def merge?
commit.message.start_with?('Merge branch')
end
def revert?
commit.message.start_with?('Revert "')
end
def multi_line?
!details.nil? && !details.empty?
end
def failed?
problems.any?
end
def add_problem(problem_key, *args)
@problems[problem_key] = sprintf(PROBLEMS[problem_key], *args)
end
def lint(subject_description = "commit subject")
return self if @linted
@linted = true
lint_subject(subject_description)
lint_separator
lint_details
lint_message
self
end
def lint_subject(subject_description)
if subject_too_short?
add_problem(:subject_too_short, subject_description)
end
if subject_too_long?
add_problem(:subject_too_long, subject_description)
elsif subject_above_warning?
add_problem(:subject_above_warning, subject_description)
end
if subject_starts_with_lowercase?
add_problem(:subject_starts_with_lowercase, subject_description)
end
if subject_ends_with_a_period?
add_problem(:subject_ends_with_a_period, subject_description)
end
self
end
private
def lint_separator
return self unless separator && !separator.empty?
add_problem(:separator_missing)
self
end
def lint_details
if !multi_line? && many_changes?
add_problem(:details_too_many_changes)
end
details&.each_line do |line|
line = line.strip
next unless line_too_long?(line)
url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length } # rubocop:disable CodeReuse/ActiveRecord
# If the line includes a URL, we'll allow it to exceed MAX_LINE_LENGTH characters, but
# only if the line _without_ the URL does not exceed this limit.
next unless line_too_long?(line.length - url_size)
add_problem(:details_line_too_long)
break
end
self
end
def lint_message
if message_contains_text_emoji?
add_problem(:message_contains_text_emoji)
end
if message_contains_unicode_emoji?
add_problem(:message_contains_unicode_emoji)
end
if message_contains_short_reference?
add_problem(:message_contains_short_reference)
end
self
end
def files_changed
commit.diff_parent.stats[:total][:files]
end
def lines_changed
commit.diff_parent.stats[:total][:lines]
end
def many_changes?
files_changed > MAX_CHANGED_FILES_IN_COMMIT && lines_changed > MAX_CHANGED_LINES_IN_COMMIT
end
def subject
message_parts[0]
end
def separator
message_parts[1]
end
def details
message_parts[2]
end
def line_too_long?(line)
case line
when String
line.length > MAX_LINE_LENGTH
when Integer
line > MAX_LINE_LENGTH
else
raise ArgumentError, "The line argument (#{line}) should be a String or an Integer! #{line.class} given."
end
end
def subject_too_short?
subject.split(' ').length < MIN_SUBJECT_WORDS_COUNT
end
def subject_too_long?
line_too_long?(subject)
end
def subject_above_warning?
subject.length > WARN_SUBJECT_LENGTH
end
def subject_starts_with_lowercase?
first_char = subject[0]
first_char.downcase == first_char
end
def subject_ends_with_a_period?
subject.end_with?('.')
end
def message_contains_text_emoji?
emoji_checker.includes_text_emoji?(commit.message)
end
def message_contains_unicode_emoji?
emoji_checker.includes_unicode_emoji?(commit.message)
end
def message_contains_short_reference?
commit.message.match?(SHORT_REFERENCE_REGEX)
end
def emoji_checker
@emoji_checker ||= Gitlab::Danger::EmojiChecker.new
end
def message_parts
@message_parts ||= commit.message.split("\n", 3)
end
end
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'json'
module Gitlab
module Danger
class EmojiChecker
DIGESTS = File.expand_path('../../../fixtures/emojis/digests.json', __dir__)
ALIASES = File.expand_path('../../../fixtures/emojis/aliases.json', __dir__)
# A regex that indicates a piece of text _might_ include an Emoji. The regex
# alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this
# regex to save us from having to check for all possible emoji names when we
# know one definitely is not included.
LIKELY_EMOJI = /:[\+a-z0-9_\-]+:/.freeze
UNICODE_EMOJI_REGEX = %r{(
[\u{1F300}-\u{1F5FF}] |
[\u{1F1E6}-\u{1F1FF}] |
[\u{2700}-\u{27BF}] |
[\u{1F900}-\u{1F9FF}] |
[\u{1F600}-\u{1F64F}] |
[\u{1F680}-\u{1F6FF}] |
[\u{2600}-\u{26FF}]
)}x.freeze
def initialize
names = JSON.parse(File.read(DIGESTS)).keys +
JSON.parse(File.read(ALIASES)).keys
@emoji = names.map { |name| ":#{name}:" }
end
def includes_text_emoji?(text)
return false unless text.match?(LIKELY_EMOJI)
@emoji.any? { |emoji| text.include?(emoji) }
end
def includes_unicode_emoji?(text)
text.match?(UNICODE_EMOJI_REGEX)
end
end
end
end

View File

@ -174,6 +174,10 @@ module Gitlab
labels - current_mr_labels
end
def sanitize_mr_title(title)
title.gsub(/^WIP: */, '').gsub(/`/, '\\\`')
end
def security_mr?
return false unless gitlab_helper

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Gitlab
module ErrorTracking
class Repo
attr_accessor :status, :integration_id, :project_id
def initialize(status:, integration_id:, project_id:)
@status = status
@integration_id = integration_id
@project_id = project_id
end
end
end
end

View File

@ -3,6 +3,8 @@
module Gitlab
module ImportExport
class RelationFactory
include Gitlab::Utils::StrongMemoize
prepend_if_ee('::EE::Gitlab::ImportExport::RelationFactory') # rubocop: disable Cop/InjectEnterpriseEditionModule
OVERRIDES = { snippets: :project_snippets,
@ -40,7 +42,7 @@ module Gitlab
IMPORTED_OBJECT_MAX_RETRIES = 5.freeze
EXISTING_OBJECT_CHECK = %i[
EXISTING_OBJECT_RELATIONS = %i[
milestone
milestones
label
@ -58,9 +60,6 @@ module Gitlab
TOKEN_RESET_MODELS = %i[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
# This represents all relations that have unique key on `project_id`
UNIQUE_RELATIONS = %i[project_feature ProjectCiCdSetting container_expiration_policy].freeze
def self.create(*args)
new(*args).create
end
@ -115,12 +114,18 @@ module Gitlab
OVERRIDES
end
def self.existing_object_check
EXISTING_OBJECT_CHECK
def self.existing_object_relations
EXISTING_OBJECT_RELATIONS
end
private
def existing_object?
strong_memoize(:_existing_object) do
self.class.existing_object_relations.include?(@relation_name) || unique_relation?
end
end
def setup_models
case @relation_name
when :merge_request_diff_files then setup_diff
@ -229,7 +234,7 @@ module Gitlab
end
def update_group_references
return unless self.class.existing_object_check.include?(@relation_name)
return unless existing_object?
return unless @relation_hash['group_id']
@relation_hash['group_id'] = @project.namespace_id
@ -322,7 +327,7 @@ module Gitlab
# Only find existing records to avoid mapping tables such as milestones
# Otherwise always create the record, skipping the extra SELECT clause.
@existing_or_new_object ||= begin
if self.class.existing_object_check.include?(@relation_name)
if existing_object?
attribute_hash = attribute_hash_for(['events'])
existing_object.assign_attributes(attribute_hash) if attribute_hash.any?
@ -356,8 +361,43 @@ module Gitlab
!Object.const_defined?(parsed_relation_hash['type'])
end
def unique_relation?
strong_memoize(:unique_relation) do
project_foreign_key.present? &&
(has_unique_index_on_project_fk? || uses_project_fk_as_primary_key?)
end
end
def has_unique_index_on_project_fk?
cache = cached_has_unique_index_on_project_fk
table_name = relation_class.table_name
return cache[table_name] if cache.has_key?(table_name)
index_exists =
ActiveRecord::Base.connection.index_exists?(
relation_class.table_name,
project_foreign_key,
unique: true)
cache[table_name] = index_exists
end
# Avoid unnecessary DB requests
def cached_has_unique_index_on_project_fk
Thread.current[:cached_has_unique_index_on_project_fk] ||= {}
end
def uses_project_fk_as_primary_key?
relation_class.primary_key == project_foreign_key
end
# Should be `:project_id` for most of the cases, but this is more general
def project_foreign_key
relation_class.reflect_on_association(:project)&.foreign_key
end
def find_or_create_object!
if UNIQUE_RELATIONS.include?(@relation_name)
if unique_relation?
unique_relation_object = relation_class.find_or_create_by(project_id: @project.id)
unique_relation_object.assign_attributes(parsed_relation_hash)

View File

@ -22,11 +22,8 @@ module Gitlab
def pool_size
# heuristic constant 5 should be a config setting somewhere -- related to CPU count?
size = 5
if Gitlab::Runtime.sidekiq?
# the pool will be used in a multi-threaded context
size += Sidekiq.options[:concurrency]
elsif Gitlab::Runtime.puma?
size += Puma.cli_config.options[:max_threads]
if Gitlab::Runtime.multi_threaded?
size += Gitlab::Runtime.max_threads
end
size

View File

@ -10,7 +10,7 @@ module Gitlab
def self.server_configurator(metrics: true, arguments_logger: true, memory_killer: true, request_store: true)
lambda do |chain|
chain.add Gitlab::SidekiqMiddleware::Monitor
chain.add Gitlab::SidekiqMiddleware::Metrics if metrics
chain.add Gitlab::SidekiqMiddleware::ServerMetrics if metrics
chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if arguments_logger
chain.add Gitlab::SidekiqMiddleware::MemoryKiller if memory_killer
chain.add Gitlab::SidekiqMiddleware::RequestStoreMiddleware if request_store
@ -27,6 +27,7 @@ module Gitlab
def self.client_configurator
lambda do |chain|
chain.add Gitlab::SidekiqStatus::ClientMiddleware
chain.add Gitlab::SidekiqMiddleware::ClientMetrics
chain.add Labkit::Middleware::Sidekiq::Client
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module Gitlab
module SidekiqMiddleware
class ClientMetrics < SidekiqMiddleware::Metrics
ENQUEUED = :sidekiq_enqueued_jobs_total
def initialize
@metrics = init_metrics
end
def call(worker, _job, queue, _redis_pool)
labels = create_labels(worker.class, queue)
@metrics.fetch(ENQUEUED).increment(labels, 1)
yield
end
private
def init_metrics
{
ENQUEUED => ::Gitlab::Metrics.counter(ENQUEUED, 'Sidekiq jobs enqueued')
}
end
end
end
end

View File

@ -3,68 +3,11 @@
module Gitlab
module SidekiqMiddleware
class Metrics
# SIDEKIQ_LATENCY_BUCKETS are latency histogram buckets better suited to Sidekiq
# timeframes than the DEFAULT_BUCKET definition. Defined in seconds.
SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze
TRUE_LABEL = "yes"
FALSE_LABEL = "no"
def initialize
@metrics = init_metrics
@metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i)
end
def call(worker, job, queue)
labels = create_labels(worker.class, queue)
queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job)
@metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration
@metrics[:sidekiq_running_jobs].increment(labels, 1)
if job['retry_count'].present?
@metrics[:sidekiq_jobs_retried_total].increment(labels, 1)
end
job_succeeded = false
monotonic_time_start = Gitlab::Metrics::System.monotonic_time
job_thread_cputime_start = get_thread_cputime
begin
yield
job_succeeded = true
ensure
monotonic_time_end = Gitlab::Metrics::System.monotonic_time
job_thread_cputime_end = get_thread_cputime
monotonic_time = monotonic_time_end - monotonic_time_start
job_thread_cputime = job_thread_cputime_end - job_thread_cputime_start
# sidekiq_running_jobs, sidekiq_jobs_failed_total should not include the job_status label
@metrics[:sidekiq_running_jobs].increment(labels, -1)
@metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded
# job_status: done, fail match the job_status attribute in structured logging
labels[:job_status] = job_succeeded ? "done" : "fail"
@metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime)
@metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time)
end
end
private
def init_metrics
{
sidekiq_jobs_cpu_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_cpu_seconds, 'Seconds of cpu time to run Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_completion_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_completion_seconds, 'Seconds to complete Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_queue_duration_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_queue_duration_seconds, 'Duration in seconds that a Sidekiq job was queued before being executed', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_failed_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_failed_total, 'Sidekiq jobs failed'),
sidekiq_jobs_retried_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_retried_total, 'Sidekiq jobs retried'),
sidekiq_running_jobs: ::Gitlab::Metrics.gauge(:sidekiq_running_jobs, 'Number of Sidekiq jobs running', {}, :all),
sidekiq_concurrency: ::Gitlab::Metrics.gauge(:sidekiq_concurrency, 'Maximum number of Sidekiq jobs', {}, :all)
}
end
def create_labels(worker_class, queue)
labels = { queue: queue.to_s, latency_sensitive: FALSE_LABEL, external_dependencies: FALSE_LABEL, feature_category: "", boundary: "" }
return labels unless worker_class.include? WorkerAttributes
@ -84,10 +27,6 @@ module Gitlab
def bool_as_label(value)
value ? TRUE_LABEL : FALSE_LABEL
end
def get_thread_cputime
defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0
end
end
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Gitlab
module SidekiqMiddleware
class ServerMetrics < SidekiqMiddleware::Metrics
# SIDEKIQ_LATENCY_BUCKETS are latency histogram buckets better suited to Sidekiq
# timeframes than the DEFAULT_BUCKET definition. Defined in seconds.
SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze
def initialize
@metrics = init_metrics
@metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i)
end
def call(worker, job, queue)
labels = create_labels(worker.class, queue)
queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job)
@metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration
@metrics[:sidekiq_running_jobs].increment(labels, 1)
if job['retry_count'].present?
@metrics[:sidekiq_jobs_retried_total].increment(labels, 1)
end
job_succeeded = false
monotonic_time_start = Gitlab::Metrics::System.monotonic_time
job_thread_cputime_start = get_thread_cputime
begin
yield
job_succeeded = true
ensure
monotonic_time_end = Gitlab::Metrics::System.monotonic_time
job_thread_cputime_end = get_thread_cputime
monotonic_time = monotonic_time_end - monotonic_time_start
job_thread_cputime = job_thread_cputime_end - job_thread_cputime_start
# sidekiq_running_jobs, sidekiq_jobs_failed_total should not include the job_status label
@metrics[:sidekiq_running_jobs].increment(labels, -1)
@metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded
# job_status: done, fail match the job_status attribute in structured logging
labels[:job_status] = job_succeeded ? "done" : "fail"
@metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime)
@metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time)
end
end
private
def init_metrics
{
sidekiq_jobs_cpu_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_cpu_seconds, 'Seconds of cpu time to run Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_completion_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_completion_seconds, 'Seconds to complete Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_queue_duration_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_queue_duration_seconds, 'Duration in seconds that a Sidekiq job was queued before being executed', {}, SIDEKIQ_LATENCY_BUCKETS),
sidekiq_jobs_failed_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_failed_total, 'Sidekiq jobs failed'),
sidekiq_jobs_retried_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_retried_total, 'Sidekiq jobs retried'),
sidekiq_running_jobs: ::Gitlab::Metrics.gauge(:sidekiq_running_jobs, 'Number of Sidekiq jobs running', {}, :all),
sidekiq_concurrency: ::Gitlab::Metrics.gauge(:sidekiq_concurrency, 'Maximum number of Sidekiq jobs', {}, :all)
}
end
def get_thread_cputime
defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0
end
end
end
end

View File

@ -5,6 +5,8 @@ module Sentry
include Sentry::Client::Event
include Sentry::Client::Projects
include Sentry::Client::Issue
include Sentry::Client::Repo
include Sentry::Client::IssueLink
Error = Class.new(StandardError)
MissingKeysError = Class.new(StandardError)
@ -79,7 +81,7 @@ module Sentry
end
def handle_response(response)
unless response.code == 200
unless response.code.between?(200, 204)
raise_error "Sentry response status code: #{response.code}"
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Sentry
class Client
module IssueLink
def create_issue_link(integration_id, sentry_issue_identifier, issue)
issue_link_url = issue_link_api_url(integration_id, sentry_issue_identifier)
params = {
project: issue.project.id,
externalIssue: "#{issue.project.id}##{issue.iid}"
}
http_put(issue_link_url, params)
end
private
def issue_link_api_url(integration_id, sentry_issue_identifier)
issue_link_url = URI(url)
issue_link_url.path = "/api/0/groups/#{sentry_issue_identifier}/integrations/#{integration_id}/"
issue_link_url
end
end
end
end

38
lib/sentry/client/repo.rb Normal file
View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Sentry
class Client
module Repo
def repos(organization_slug)
repos_url = repos_api_url(organization_slug)
repos = http_get(repos_url)[:body]
handle_mapping_exceptions do
map_to_repos(repos)
end
end
private
def repos_api_url(organization_slug)
repos_url = URI(url)
repos_url.path = "/api/0/organizations/#{organization_slug}/repos/"
repos_url
end
def map_to_repos(repos)
repos.map(&method(:map_to_repo))
end
def map_to_repo(repo)
Gitlab::ErrorTracking::Repo.new(
status: repo.fetch('status'),
integration_id: repo.fetch('integrationId'),
project_id: repo.fetch('externalSlug')
)
end
end
end
end

View File

@ -691,18 +691,9 @@ msgstr ""
msgid "<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes"
msgstr ""
msgid "<strong>%{created_count}</strong> created, <strong>%{accepted_count}</strong> accepted."
msgstr ""
msgid "<strong>%{created_count}</strong> created, <strong>%{closed_count}</strong> closed."
msgstr ""
msgid "<strong>%{group_name}</strong> group members"
msgstr ""
msgid "<strong>%{pushes}</strong> pushes, more than <strong>%{commits}</strong> commits by <strong>%{people}</strong> contributors."
msgstr ""
msgid "<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes"
msgstr ""
@ -742,6 +733,9 @@ msgstr ""
msgid "A deleted user"
msgstr ""
msgid "A file with '%{file_name}' already exists in %{branch} branch"
msgstr ""
msgid "A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project."
msgstr ""
@ -2423,6 +2417,9 @@ msgstr ""
msgid "AutoDevOps|Auto DevOps"
msgstr ""
msgid "AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away."
msgstr ""
msgid "AutoDevOps|Auto DevOps documentation"
msgstr ""
@ -2999,9 +2996,6 @@ msgstr ""
msgid "CICD|Auto DevOps"
msgstr ""
msgid "CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration."
msgstr ""
msgid "CICD|Automatic deployment to staging, manual deployment to production"
msgstr ""
@ -3023,9 +3017,6 @@ msgstr ""
msgid "CICD|Jobs"
msgstr ""
msgid "CICD|Learn more about Auto DevOps"
msgstr ""
msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found."
msgstr ""
@ -4672,6 +4663,9 @@ msgstr ""
msgid "Commit message"
msgstr ""
msgid "Commit message (optional)"
msgstr ""
msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
msgstr ""
@ -5046,6 +5040,42 @@ msgstr ""
msgid "Contribution Charts"
msgstr ""
msgid "ContributionAnalytics|<strong>%{created_count}</strong> created, <strong>%{accepted_count}</strong> accepted."
msgstr ""
msgid "ContributionAnalytics|<strong>%{created_count}</strong> created, <strong>%{closed_count}</strong> closed."
msgstr ""
msgid "ContributionAnalytics|<strong>%{pushes}</strong> pushes, more than <strong>%{commits}</strong> commits by <strong>%{people}</strong> contributors."
msgstr ""
msgid "ContributionAnalytics|Contribution analytics for issues, merge requests and push events since %{start_date}"
msgstr ""
msgid "ContributionAnalytics|Issues"
msgstr ""
msgid "ContributionAnalytics|Last 3 months"
msgstr ""
msgid "ContributionAnalytics|Last month"
msgstr ""
msgid "ContributionAnalytics|Last week"
msgstr ""
msgid "ContributionAnalytics|Merge Requests"
msgstr ""
msgid "ContributionAnalytics|No issues for the selected time period."
msgstr ""
msgid "ContributionAnalytics|No merge requests for the selected time period."
msgstr ""
msgid "ContributionAnalytics|No pushes for the selected time period."
msgstr ""
msgid "Contributions for <strong>%{calendar_date}</strong>"
msgstr ""
@ -6930,9 +6960,6 @@ msgstr ""
msgid "Enter zen mode"
msgstr ""
msgid "EnviornmentDashboard|You are looking at the last updated environment"
msgstr ""
msgid "Environment"
msgstr ""
@ -6951,6 +6978,9 @@ msgstr ""
msgid "EnvironmentDashboard|Created through the Deployment API"
msgstr ""
msgid "EnvironmentDashboard|You are looking at the last updated environment"
msgstr ""
msgid "Environments"
msgstr ""
@ -8013,6 +8043,9 @@ msgstr ""
msgid "File moved"
msgstr ""
msgid "File name"
msgstr ""
msgid "File templates"
msgstr ""
@ -9345,9 +9378,6 @@ msgstr ""
msgid "GroupSettings|Auto DevOps pipeline was updated for the group"
msgstr ""
msgid "GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}"
msgstr ""
msgid "GroupSettings|Badges"
msgstr ""
@ -11605,6 +11635,9 @@ msgstr ""
msgid "Metrics|Check out the CI/CD documentation on deploying to an environment"
msgstr ""
msgid "Metrics|Create custom dashboard %{fileName}"
msgstr ""
msgid "Metrics|Create metric"
msgstr ""
@ -11614,6 +11647,15 @@ msgstr ""
msgid "Metrics|Delete metric?"
msgstr ""
msgid "Metrics|Duplicate"
msgstr ""
msgid "Metrics|Duplicate dashboard"
msgstr ""
msgid "Metrics|Duplicating..."
msgstr ""
msgid "Metrics|Edit metric"
msgstr ""
@ -11650,6 +11692,12 @@ msgstr ""
msgid "Metrics|Show last"
msgstr ""
msgid "Metrics|There was an error creating the dashboard."
msgstr ""
msgid "Metrics|There was an error creating the dashboard. %{error}"
msgstr ""
msgid "Metrics|There was an error fetching the environments data, please try again"
msgstr ""
@ -11689,6 +11737,9 @@ msgstr ""
msgid "Metrics|Y-axis label"
msgstr ""
msgid "Metrics|You can save a copy of this dashboard to your repository so it can be customized. Select a file name and branch to save it."
msgstr ""
msgid "Metrics|You're about to permanently delete this metric. This cannot be undone."
msgstr ""
@ -12227,9 +12278,6 @@ msgstr ""
msgid "No forks are available to you."
msgstr ""
msgid "No issues for the selected time period."
msgstr ""
msgid "No job log"
msgstr ""
@ -12248,9 +12296,6 @@ msgstr ""
msgid "No matching results"
msgstr ""
msgid "No merge requests for the selected time period."
msgstr ""
msgid "No merge requests found"
msgstr ""
@ -12278,9 +12323,6 @@ msgstr ""
msgid "No public groups"
msgstr ""
msgid "No pushes for the selected time period."
msgstr ""
msgid "No repository"
msgstr ""
@ -15542,6 +15584,9 @@ msgstr ""
msgid "Request Access"
msgstr ""
msgid "Request parameter %{param} is missing."
msgstr ""
msgid "Request to link SAML account must be authorized"
msgstr ""
@ -15728,6 +15773,12 @@ msgstr ""
msgid "Review"
msgstr ""
msgid "Review App|View app"
msgstr ""
msgid "Review App|View latest app"
msgstr ""
msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
msgstr ""
@ -18283,6 +18334,9 @@ msgstr ""
msgid "The file has been successfully deleted."
msgstr ""
msgid "The file name should have a .yml extension"
msgstr ""
msgid "The following items will NOT be exported:"
msgstr ""
@ -18594,6 +18648,12 @@ msgstr ""
msgid "There was an error adding a To Do."
msgstr ""
msgid "There was an error creating the dashboard, branch name is invalid."
msgstr ""
msgid "There was an error creating the dashboard, branch named: %{branch} already exists."
msgstr ""
msgid "There was an error creating the issue"
msgstr ""
@ -20485,9 +20545,6 @@ msgstr ""
msgid "View Documentation"
msgstr ""
msgid "View app"
msgstr ""
msgid "View blame prior to this change"
msgstr ""
@ -20547,9 +20604,6 @@ msgstr ""
msgid "View open merge request"
msgstr ""
msgid "View previous app"
msgstr ""
msgid "View project"
msgstr ""
@ -20562,6 +20616,9 @@ msgstr ""
msgid "View the documentation"
msgstr ""
msgid "View the latest successful deployment to this environment"
msgstr ""
msgid "Viewing commit"
msgstr ""
@ -21164,6 +21221,9 @@ msgstr ""
msgid "You can try again using %{begin_link}basic search%{end_link}"
msgstr ""
msgid "You can't commit to this project"
msgstr ""
msgid "You cannot access the raw file. Please wait a minute."
msgstr ""
@ -21491,6 +21551,9 @@ msgstr ""
msgid "Your comment could not be updated! Please check your network connection and try again."
msgstr ""
msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}."
msgstr ""
msgid "Your deployment services will be broken, you will need to manually fix the services after renaming."
msgstr ""

View File

@ -72,4 +72,5 @@ Disallow: /*/*/protected_branches
Disallow: /*/*/uploads/
Disallow: /*/-/group_members
Disallow: /*/project_members
Disallow: /groups/*/-/contribution_analytics
Disallow: /groups/*/-/analytics

View File

@ -37,142 +37,70 @@ describe Projects::PerformanceMonitoring::DashboardsController do
end
context 'valid parameters' do
it 'delegates commit creation to service' do
it 'delegates cloning to ::Metrics::Dashboard::CloneDashboardService' do
allow(controller).to receive(:repository).and_return(repository)
allow(repository).to receive(:find_branch).and_return(branch)
dashboard_attrs = {
dashboard: dashboard,
file_name: file_name,
commit_message: commit_message,
branch_name: branch_name,
start_branch: 'master',
encoding: 'text',
file_path: '.gitlab/dashboards/custom_dashboard.yml',
file_content: File.read('config/prometheus/common_metrics.yml')
branch: branch_name
}
service_instance = instance_double(::Files::CreateService)
expect(::Files::CreateService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance)
expect(service_instance).to receive(:execute).and_return(status: :success)
service_instance = instance_double(::Metrics::Dashboard::CloneDashboardService)
expect(::Metrics::Dashboard::CloneDashboardService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance)
expect(service_instance).to receive(:execute).and_return(status: :success, http_status: :created, dashboard: { path: 'dashboard/path' })
post :create, params: params
end
it 'extends dashboard template path to absolute url' do
allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :success }))
allow(controller).to receive(:repository).and_return(repository)
allow(repository).to receive(:find_branch).and_return(branch)
expect(File).to receive(:read).with(Rails.root.join('config/prometheus/common_metrics.yml')).and_return('')
post :create, params: params
end
context 'selected branch already exists' do
it 'responds with :created status code', :aggregate_failures do
repository.add_branch(user, branch_name, 'master')
post :create, params: params
expect(response).to have_gitlab_http_status :created
end
end
context 'request format json' do
it 'returns path to new file' do
allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :success }))
it 'returns services response' do
allow(::Metrics::Dashboard::CloneDashboardService).to receive(:new).and_return(double(execute: { status: :success, dashboard: { path: ".gitlab/dashboards/#{file_name}" }, http_status: :created }))
allow(controller).to receive(:repository).and_return(repository)
expect(repository).to receive(:find_branch).with(branch_name).and_return(branch)
allow(repository).to receive(:find_branch).and_return(branch)
post :create, params: params
expect(response).to have_gitlab_http_status :created
expect(json_response).to eq('redirect_to' => "/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}")
expect(response).to set_flash[:notice].to eq("Your dashboard has been copied. You can <a href=\"/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.")
expect(json_response).to eq('status' => 'success', 'dashboard' => { 'path' => ".gitlab/dashboards/#{file_name}" })
end
context 'files create service failure' do
it 'returns json with failure message' do
allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: false, message: 'something went wrong' }))
context 'Metrics::Dashboard::CloneDashboardService failure' do
it 'returns json with failure message', :aggregate_failures do
allow(::Metrics::Dashboard::CloneDashboardService).to receive(:new).and_return(double(execute: { status: :error, message: 'something went wrong', http_status: :bad_request }))
post :create, params: params
expect(response).to have_gitlab_http_status :bad_request
expect(response).to set_flash[:alert].to eq('something went wrong')
expect(json_response).to eq('error' => 'something went wrong')
end
end
end
context 'request format html' do
before do
params.delete(:format)
end
%w(commit_message file_name dashboard).each do |param|
context "param #{param} is missing" do
let(param.to_s) { nil }
it 'redirects to ide with new file' do
allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :success }))
allow(controller).to receive(:repository).and_return(repository)
it 'responds with bad request status and error message', :aggregate_failures do
post :create, params: params
expect(repository).to receive(:find_branch).with(branch_name).and_return(branch)
post :create, params: params
expect(response).to redirect_to "/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}"
end
context 'files create service failure' do
it 'redirects back and sets alert' do
allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: false, message: 'something went wrong' }))
allow(controller).to receive(:repository).and_return(repository)
allow(repository).to receive(:find_branch).and_return(branch)
post :create, params: params
expect(response).to set_flash[:alert].to eq('something went wrong')
expect(response).to redirect_to namespace_project_environments_path
expect(response).to have_gitlab_http_status :bad_request
expect(json_response).to eq('error' => "Request parameter #{param} is missing.")
end
end
end
end
end
context 'invalid dashboard template' do
let(:dashboard) { 'config/database.yml' }
context "param branch_name is missing" do
let(:branch_name) { nil }
it 'responds 404 not found' do
post :create, params: params
it 'responds with bad request status and error message', :aggregate_failures do
post :create, params: params
expect(response).to have_gitlab_http_status :not_found
end
end
context 'missing commit message' do
before do
params.delete(:commit_message)
end
it 'use default commit message' do
allow(controller).to receive(:repository).and_return(repository)
allow(repository).to receive(:find_branch).and_return(branch)
dashboard_attrs = {
commit_message: 'Create custom dashboard custom_dashboard.yml',
branch_name: branch_name,
start_branch: 'master',
encoding: 'text',
file_path: ".gitlab/dashboards/custom_dashboard.yml",
file_content: File.read('config/prometheus/common_metrics.yml')
}
service_instance = instance_double(::Files::CreateService)
expect(::Files::CreateService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance)
expect(service_instance).to receive(:execute).and_return(status: :success)
post :create, params: params
end
end
context 'missing branch' do
let(:branch_name) { nil }
it 'raises ActionController::ParameterMissing' do
expect { post :create, params: params }.to raise_error ActionController::ParameterMissing
expect(response).to have_gitlab_http_status :bad_request
expect(json_response).to eq('error' => "Request parameter branch is missing.")
end
end
end
end
end

View File

@ -0,0 +1,7 @@
{
"url": "https://gitlab.com/test/tanuki-inc/issues/3",
"integrationId": 44444,
"displayName": "test/tanuki-inc#3",
"id": 140319,
"key": "gitlab.com/test:test/tanuki-inc#3"
}

View File

@ -0,0 +1,15 @@
[
{
"status": "active",
"integrationId": "48066",
"externalSlug": 139,
"name": "test / tanuki-inc",
"provider": {
"id": "integrations:gitlab",
"name": "Gitlab"
},
"url": "https://gitlab.com/test/tanuki-inc",
"id": "52480",
"dateCreated": "2020-01-08T21:15:17.181520Z"
}
]

View File

@ -11,7 +11,6 @@ describe('Issuable suggestions app component', () => {
search,
projectPath: 'project',
},
attachToDocument: true,
});
}

View File

@ -16,7 +16,6 @@ describe('Issuable suggestions suggestion component', () => {
...suggestion,
},
},
attachToDocument: true,
});
}

View File

@ -44,7 +44,6 @@ describe('Issuable component', () => {
baseUrl: TEST_BASE_URL,
...props,
},
attachToDocument: true,
});
};

View File

@ -45,7 +45,6 @@ describe('Issuables list component', () => {
emptySvgPath: TEST_EMPTY_SVG_PATH,
...props,
},
attachToDocument: true,
});
};

View File

@ -6,6 +6,8 @@ import axios from '~/lib/utils/axios_utils';
import statusCodes from '~/lib/utils/http_status';
import { metricStates } from '~/monitoring/constants';
import Dashboard from '~/monitoring/components/dashboard.vue';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_picker.vue';
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
import { createStore } from '~/monitoring/stores';
@ -465,7 +467,7 @@ describe('Dashboard', () => {
wrapper.vm
.$nextTick()
.then(() => {
const dashboardDropdown = wrapper.find('.js-dashboards-dropdown');
const dashboardDropdown = wrapper.find(DashboardsDropdown);
expect(dashboardDropdown.exists()).toBe(true);
done();

View File

@ -0,0 +1,249 @@
import { shallowMount } from '@vue/test-utils';
import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue';
import { dashboardGitResponse } from '../mock_data';
const defaultBranch = 'master';
function createComponent(props, opts = {}) {
const storeOpts = {
methods: {
duplicateSystemDashboard: jest.fn(),
},
computed: {
allDashboards: () => dashboardGitResponse,
},
};
return shallowMount(DashboardsDropdown, {
propsData: {
...props,
defaultBranch,
},
sync: false,
...storeOpts,
...opts,
});
}
describe('DashboardsDropdown', () => {
let wrapper;
const findItems = () => wrapper.findAll(GlDropdownItem);
const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i);
describe('when it receives dashboards data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays an item for each dashboard', () => {
expect(wrapper.findAll(GlDropdownItem).length).toEqual(dashboardGitResponse.length);
});
it('displays items with the dashboard display name', () => {
expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name);
expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name);
expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name);
});
});
describe('when a system dashboard is selected', () => {
let duplicateDashboardAction;
let modalDirective;
beforeEach(() => {
modalDirective = jest.fn();
duplicateDashboardAction = jest.fn().mockResolvedValue();
wrapper = createComponent(
{
selectedDashboard: dashboardGitResponse[0],
},
{
directives: {
GlModal: modalDirective,
},
methods: {
// Mock vuex actions
duplicateSystemDashboard: duplicateDashboardAction,
},
},
);
wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn();
});
it('displays an item for each dashboard plus a "duplicate dashboard" item', () => {
const item = wrapper.findAll({ ref: 'duplicateDashboardItem' });
expect(findItems().length).toEqual(dashboardGitResponse.length + 1);
expect(item.length).toBe(1);
});
describe('modal form', () => {
let okEvent;
const findModal = () => wrapper.find(GlModal);
const findAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
okEvent = {
preventDefault: jest.fn(),
};
});
it('exists and contains a form to duplicate a dashboard', () => {
expect(findModal().exists()).toBe(true);
expect(findModal().contains(DuplicateDashboardForm)).toBe(true);
});
it('saves a new dashboard', done => {
findModal().vm.$emit('ok', okEvent);
waitForPromises()
.then(() => {
expect(okEvent.preventDefault).toHaveBeenCalled();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled();
expect(wrapper.emitted().selectDashboard).toBeTruthy();
expect(findAlert().exists()).toBe(false);
done();
})
.catch(done.fail);
});
describe('when a new dashboard is saved succesfully', () => {
const newDashboard = {
can_edit: true,
default: false,
display_name: 'A new dashboard',
system_dashboard: false,
};
const submitForm = formVals => {
duplicateDashboardAction.mockResolvedValueOnce(newDashboard);
findModal()
.find(DuplicateDashboardForm)
.vm.$emit('change', {
dashboard: 'common_metrics.yml',
commitMessage: 'A commit message',
...formVals,
});
findModal().vm.$emit('ok', okEvent);
};
it('to the default branch, redirects to the new dashboard', done => {
submitForm({
branch: defaultBranch,
});
waitForPromises()
.then(() => {
expect(wrapper.emitted().selectDashboard[0][0]).toEqual(newDashboard);
done();
})
.catch(done.fail);
});
it('to a new branch refreshes in the current dashboard', done => {
submitForm({
branch: 'another-branch',
});
waitForPromises()
.then(() => {
expect(wrapper.emitted().selectDashboard[0][0]).toEqual(dashboardGitResponse[0]);
done();
})
.catch(done.fail);
});
});
it('handles error when a new dashboard is not saved', done => {
const errMsg = 'An error occurred';
duplicateDashboardAction.mockRejectedValueOnce(errMsg);
findModal().vm.$emit('ok', okEvent);
waitForPromises()
.then(() => {
expect(okEvent.preventDefault).toHaveBeenCalled();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(errMsg);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('id is correct, as the value of modal directive binding matches modal id', () => {
expect(modalDirective).toHaveBeenCalledTimes(1);
// Binding's second argument contains the modal id
expect(modalDirective.mock.calls[0][1]).toEqual(
expect.objectContaining({
value: findModal().props('modalId'),
}),
);
});
it('updates the form on changes', () => {
const formVals = {
dashboard: 'common_metrics.yml',
commitMessage: 'A commit message',
};
findModal()
.find(DuplicateDashboardForm)
.vm.$emit('change', formVals);
// Binding's second argument contains the modal id
expect(wrapper.vm.form).toEqual(formVals);
});
});
});
describe('when a custom dashboard is selected', () => {
const findModal = () => wrapper.find(GlModal);
beforeEach(() => {
wrapper = createComponent({
selectedDashboard: dashboardGitResponse[1],
});
});
it('displays an item for each dashboard', () => {
const item = wrapper.findAll({ ref: 'duplicateDashboardItem' });
expect(findItems().length).toEqual(dashboardGitResponse.length);
expect(item.length).toBe(0);
});
it('modal form does not exist and contains a form to duplicate a dashboard', () => {
expect(findModal().exists()).toBe(false);
});
});
describe('when a dashboard gets selected by the user', () => {
beforeEach(() => {
wrapper = createComponent();
findItemAt(1).vm.$emit('click');
});
it('emits a "selectDashboard" event', () => {
expect(wrapper.emitted().selectDashboard).toBeTruthy();
});
it('emits a "selectDashboard" event with dashboard information', () => {
expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]);
});
});
});

View File

@ -0,0 +1,153 @@
import { mount } from '@vue/test-utils';
import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue';
import { dashboardGitResponse } from '../mock_data';
describe('DuplicateDashboardForm', () => {
let wrapper;
const defaultBranch = 'master';
const findByRef = ref => wrapper.find({ ref });
const setValue = (ref, val) => {
findByRef(ref).setValue(val);
};
const setChecked = value => {
const input = wrapper.find(`.form-check-input[value="${value}"]`);
input.element.checked = true;
input.trigger('click');
input.trigger('change');
};
beforeEach(() => {
// Use `mount` to render native input elements
wrapper = mount(DuplicateDashboardForm, {
propsData: {
dashboard: dashboardGitResponse[0],
defaultBranch,
},
sync: false,
});
});
it('renders correctly', () => {
expect(wrapper.exists()).toEqual(true);
});
it('renders form elements', () => {
expect(findByRef('fileName').exists()).toEqual(true);
expect(findByRef('branchName').exists()).toEqual(true);
expect(findByRef('branchOption').exists()).toEqual(true);
expect(findByRef('commitMessage').exists()).toEqual(true);
});
describe('validates the file name', () => {
const findInvalidFeedback = () => findByRef('fileNameFormGroup').find('.invalid-feedback');
it('when is empty', done => {
setValue('fileName', '');
wrapper.vm.$nextTick(() => {
expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true);
expect(findInvalidFeedback().exists()).toBe(false);
done();
});
});
it('when is valid', done => {
setValue('fileName', 'my_dashboard.yml');
wrapper.vm.$nextTick(() => {
expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true);
expect(findInvalidFeedback().exists()).toBe(false);
done();
});
});
it('when is not valid', done => {
setValue('fileName', 'my_dashboard.exe');
wrapper.vm.$nextTick(() => {
expect(findByRef('fileNameFormGroup').is('.is-invalid')).toBe(true);
expect(findInvalidFeedback().text()).toBeTruthy();
done();
});
});
});
describe('emits `change` event', () => {
const lastChange = () =>
wrapper.vm.$nextTick().then(() => {
wrapper.find('form').trigger('change');
// Resolves to the last emitted change
const changes = wrapper.emitted().change;
return changes[changes.length - 1][0];
});
it('with the inital form values', () => {
expect(wrapper.emitted().change).toHaveLength(1);
expect(lastChange()).resolves.toEqual({
branch: '',
commitMessage: expect.any(String),
dashboard: dashboardGitResponse[0].path,
fileName: 'common_metrics.yml',
});
});
it('containing an inputted file name', () => {
setValue('fileName', 'my_dashboard.yml');
expect(lastChange()).resolves.toMatchObject({
fileName: 'my_dashboard.yml',
});
});
it('containing a default commit message when no message is set', () => {
setValue('commitMessage', '');
expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('Create custom dashboard'),
});
});
it('containing an inputted commit message', () => {
setValue('commitMessage', 'My commit message');
expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('My commit message'),
});
});
it('containing an inputted branch name', () => {
setValue('branchName', 'a-new-branch');
expect(lastChange()).resolves.toMatchObject({
branch: 'a-new-branch',
});
});
it('when a `default` branch option is set, branch input is invisible and ignored', done => {
setChecked(wrapper.vm.$options.radioVals.DEFAULT);
setValue('branchName', 'a-new-branch');
expect(lastChange()).resolves.toMatchObject({
branch: defaultBranch,
});
wrapper.vm.$nextTick(() => {
expect(findByRef('branchName').isVisible()).toBe(false);
done();
});
});
it('when `new` branch option is chosen, focuses on the branch name input', done => {
setChecked(wrapper.vm.$options.radioVals.NEW);
wrapper.vm
.$nextTick()
.then(() => {
wrapper.find('form').trigger('change');
expect(findByRef('branchName').is(':focus')).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
});

View File

@ -15,6 +15,7 @@ export const propsData = {
clustersPath: '/path/to/clusters',
tagsPath: '/path/to/tags',
projectPath: '/path/to/project',
defaultBranch: 'master',
metricsEndpoint: mockApiEndpoint,
deploymentsEndpoint: null,
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',

View File

@ -522,6 +522,7 @@ export const dashboardGitResponse = [
default: true,
display_name: 'Default',
can_edit: false,
system_dashboard: true,
project_blob_path: null,
path: 'config/prometheus/common_metrics.yml',
},
@ -529,6 +530,7 @@ export const dashboardGitResponse = [
default: false,
display_name: 'Custom Dashboard 1',
can_edit: true,
system_dashboard: false,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`,
path: '.gitlab/dashboards/dashboard_1.yml',
},
@ -536,6 +538,7 @@ export const dashboardGitResponse = [
default: false,
display_name: 'Custom Dashboard 2',
can_edit: true,
system_dashboard: false,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`,
path: '.gitlab/dashboards/dashboard_2.yml',
},

View File

@ -18,6 +18,7 @@ import {
fetchPrometheusMetric,
setEndpoints,
setGettingStartedEmptyState,
duplicateSystemDashboard,
} from '~/monitoring/stores/actions';
import storeState from '~/monitoring/stores/state';
import {
@ -544,4 +545,85 @@ describe('Monitoring store actions', () => {
});
});
});
describe('duplicateSystemDashboard', () => {
let state;
beforeEach(() => {
state = storeState();
state.dashboardsEndpoint = '/dashboards.json';
});
it('Succesful POST request resolves', done => {
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, {
dashboard: dashboardGitResponse[1],
});
testAction(duplicateSystemDashboard, {}, state, [], [])
.then(() => {
expect(mock.history.post).toHaveLength(1);
done();
})
.catch(done.fail);
});
it('Succesful POST request resolves to a dashboard', done => {
const mockCreatedDashboard = dashboardGitResponse[1];
const params = {
dashboard: 'my-dashboard',
fileName: 'file-name.yml',
branch: 'my-new-branch',
commitMessage: 'A new commit message',
};
const expectedPayload = JSON.stringify({
dashboard: 'my-dashboard',
file_name: 'file-name.yml',
branch: 'my-new-branch',
commit_message: 'A new commit message',
});
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, {
dashboard: mockCreatedDashboard,
});
testAction(duplicateSystemDashboard, params, state, [], [])
.then(result => {
expect(mock.history.post).toHaveLength(1);
expect(mock.history.post[0].data).toEqual(expectedPayload);
expect(result).toEqual(mockCreatedDashboard);
done();
})
.catch(done.fail);
});
it('Failed POST request throws an error', done => {
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST);
testAction(duplicateSystemDashboard, {}, state, [], []).catch(err => {
expect(mock.history.post).toHaveLength(1);
expect(err).toEqual(expect.any(String));
done();
});
});
it('Failed POST request throws an error with a description', done => {
const backendErrorMsg = 'This file already exists!';
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, {
error: backendErrorMsg,
});
testAction(duplicateSystemDashboard, {}, state, [], []).catch(err => {
expect(mock.history.post).toHaveLength(1);
expect(err).toEqual(expect.any(String));
expect(err).toEqual(expect.stringContaining(backendErrorMsg));
done();
});
});
});
});

View File

@ -134,7 +134,7 @@ describe('Deployment component', () => {
if (status === SUCCESS) {
expect(wrapper.find(DeploymentViewButton).text()).toContain('View app');
} else {
expect(wrapper.find(DeploymentViewButton).text()).toContain('View previous app');
expect(wrapper.find(DeploymentViewButton).text()).toContain('View latest app');
}
});
}

View File

@ -3,6 +3,11 @@ import DeploymentViewButton from '~/vue_merge_request_widget/components/deployme
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
import deploymentMockData from './deployment_mock_data';
const appButtonText = {
text: 'View app',
tooltip: 'View the latest successful deployment to this environment',
};
describe('Deployment View App button', () => {
let wrapper;
@ -16,7 +21,7 @@ describe('Deployment View App button', () => {
factory({
propsData: {
deployment: deploymentMockData,
isCurrent: true,
appButtonText,
},
});
});
@ -26,25 +31,8 @@ describe('Deployment View App button', () => {
});
describe('text', () => {
describe('when app is current', () => {
it('shows View app', () => {
expect(wrapper.find(ReviewAppLink).text()).toContain('View app');
});
});
describe('when app is not current', () => {
beforeEach(() => {
factory({
propsData: {
deployment: deploymentMockData,
isCurrent: false,
},
});
});
it('shows View Previous app', () => {
expect(wrapper.find(ReviewAppLink).text()).toContain('View previous app');
});
it('renders text as passed', () => {
expect(wrapper.find(ReviewAppLink).text()).toContain(appButtonText.text);
});
});
@ -53,7 +41,7 @@ describe('Deployment View App button', () => {
factory({
propsData: {
deployment: { ...deploymentMockData, changes: null },
isCurrent: false,
appButtonText,
},
});
});
@ -68,7 +56,7 @@ describe('Deployment View App button', () => {
factory({
propsData: {
deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] },
isCurrent: false,
appButtonText,
},
});
});
@ -91,7 +79,7 @@ describe('Deployment View App button', () => {
factory({
propsData: {
deployment: deploymentMockData,
isCurrent: false,
appButtonText,
},
});
});

View File

@ -18,7 +18,6 @@ describe('Changed file icon', () => {
showTooltip: true,
...props,
},
attachToDocument: true,
});
};

View File

@ -9,7 +9,6 @@ describe('clipboard button', () => {
const createWrapper = propsData => {
wrapper = shallowMount(ClipboardButton, {
propsData,
attachToDocument: true,
});
};

View File

@ -17,7 +17,6 @@ describe('Commit component', () => {
const createComponent = propsData => {
wrapper = shallowMount(CommitComponent, {
propsData,
attachToDocument: true,
});
};

View File

@ -17,7 +17,6 @@ describe('IssueAssigneesComponent', () => {
assignees: mockAssigneesList,
...props,
},
attachToDocument: true,
});
vm = wrapper.vm; // eslint-disable-line
};

View File

@ -13,7 +13,6 @@ const createComponent = (milestone = mockMilestone) => {
propsData: {
milestone,
},
attachToDocument: true,
});
};

View File

@ -31,7 +31,6 @@ describe('RelatedIssuableItem', () => {
beforeEach(() => {
wrapper = mount(RelatedIssuableItem, {
slots,
attachToDocument: true,
propsData: props,
});
});

View File

@ -12,7 +12,6 @@ describe('Markdown field header component', () => {
previewMarkdown: false,
...props,
},
attachToDocument: true,
});
};

View File

@ -17,7 +17,6 @@ describe('Suggestion Diff component', () => {
...DEFAULT_PROPS,
...props,
},
attachToDocument: true,
});
};

View File

@ -16,7 +16,6 @@ describe('modal copy button', () => {
text: 'copy me',
title: 'Copy this value',
},
attachToDocument: true,
});
});

View File

@ -33,7 +33,6 @@ describe('system note component', () => {
vm = mount(IssueSystemNote, {
store,
propsData: props,
attachToDocument: true,
});
});

View File

@ -26,7 +26,6 @@ describe('Pagination links component', () => {
list: [{ id: 'foo' }, { id: 'bar' }],
props,
},
attachToDocument: true,
});
[glPaginatedList] = wrapper.vm.$children;

View File

@ -14,7 +14,6 @@ describe('Resizable Chart Container', () => {
beforeEach(() => {
wrapper = mount(ResizableChartContainer, {
attachToDocument: true,
scopedSlots: {
default: `
<div class="slot" slot-scope="{ width, height }">

View File

@ -12,7 +12,6 @@ import {
const createComponent = (config = mockConfig) =>
shallowMount(BaseComponent, {
propsData: config,
attachToDocument: true,
});
describe('BaseComponent', () => {

View File

@ -24,7 +24,6 @@ const createComponent = (
labelFilterBasePath,
enableScopedLabels: true,
},
attachToDocument: true,
});
};

View File

@ -7,7 +7,6 @@ describe('Time ago with tooltip component', () => {
const buildVm = (propsData = {}) => {
vm = shallowMount(TimeAgoTooltip, {
attachToDocument: true,
propsData,
});
};

View File

@ -26,7 +26,6 @@ describe('User Avatar Link Component', () => {
...defaultProps,
...props,
},
attachToDocument: true,
});
};

View File

@ -59,7 +59,6 @@ describe('User Popover Component', () => {
status: null,
},
},
attachToDocument: true,
},
);
});

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Environment'] do
it { expect(described_class.graphql_name).to eq('Environment') }
it 'has the expected fields' do
expected_fields = %w[
name id
]
is_expected.to have_graphql_fields(*expected_fields)
end
it { is_expected.to require_graphql_authorizations(:read_environment) }
end

View File

@ -23,7 +23,7 @@ describe GitlabSchema.types['Project'] do
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
namespace group statistics repository merge_requests merge_request issues
issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
]
is_expected.to include_graphql_fields(*expected_fields)
@ -70,4 +70,11 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::GrafanaIntegrationType) }
it { is_expected.to have_graphql_resolver(Resolvers::Projects::GrafanaIntegrationResolver) }
end
describe 'environments field' do
subject { described_class.fields['environments'] }
it { is_expected.to have_graphql_type(Types::EnvironmentType.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::EnvironmentsResolver) }
end
end

Some files were not shown because too many files have changed in this diff Show More