Commit ce839ef5 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 2e6689d5 a5c95756
......@@ -119,13 +119,12 @@ However, you can speed these cycles up somewhat by emptying the
to revert the change before merging!
To enable the Dangerfile on another existing GitLab project, run the following
extra steps, based on [this procedure](https://danger.systems/guides/getting_started.html#creating-a-bot-account-for-danger-to-use):
extra steps:
1. Add `@gitlab-bot` to the project as a `reporter`.
1. Add the `@gitlab-bot`'s `GITLAB_API_PRIVATE_TOKEN` value as a value for a new CI/CD
variable named `DANGER_GITLAB_API_TOKEN`.
1. Create a [Project access tokens](../user/project/settings/project_access_tokens.md).
1. Add the token as a CI/CD project variable named `DANGER_GITLAB_API_TOKEN`.
You should add the `~Danger bot` label to the merge request before sending it
You should add the ~"Danger bot" label to the merge request before sending it
for review.
## Current uses
......
......@@ -38,13 +38,14 @@ collected before this feature is available.
## DevOps Adoption **(ULTIMATE SELF)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/247112) in GitLab 13.7 as a [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta)
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/247112) in GitLab 13.7 as a [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta).
> - The Overview tab [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330401) in GitLab 14.1.
> - [Deployed behind a feature flag](../../../user/feature_flags.md), disabled by default.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59267) in GitLab 14.0.
> - Enabled on GitLab.com.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#disable-or-enable-devops-adoption). **(ULTIMATE SELF)**
The DevOps Adoption tab shows you which groups within your organization are using the most essential features of GitLab:
DevOps Adoption shows you which groups within your organization are using the most essential features of GitLab:
- Dev
- Issues
......@@ -67,7 +68,7 @@ DevOps Adoption allows you to:
- Identify specific groups that are lagging in their adoption of GitLab so you can help them along in their DevOps journey.
- Find the groups that have adopted certain features and can provide guidance to other groups on how to use those features.
![DevOps Report](img/admin_devops_adoption_v14_0.png)
![DevOps Report](img/admin_devops_adoption_v14_1.png)
### Disable or enable DevOps Adoption
......
......@@ -8,6 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/321083) in GitLab 13.11 as a [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta).
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/333556) in GitLab 14.1.
> - The Overview tab [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330401) in GitLab 14.1.
Prerequisites:
......@@ -38,7 +39,7 @@ With DevOps Adoption you can:
- Identify specific sub-groups that are lagging in their adoption of GitLab so you can help them along in their DevOps journey.
- Find the sub-groups that have adopted certain features and can provide guidance to other sub-groups on how to use those features.
![DevOps Report](img/group_devops_adoption_v14_0.png)
![DevOps Report](img/group_devops_adoption_v14_1.png)
## Enable data processing
......
......@@ -22,6 +22,7 @@ import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates';
import { shouldPollTableData } from '../utils/helpers';
import DevopsAdoptionAddDropdown from './devops_adoption_add_dropdown.vue';
import DevopsAdoptionOverview from './devops_adoption_overview.vue';
import DevopsAdoptionSection from './devops_adoption_section.vue';
export default {
......@@ -30,6 +31,7 @@ export default {
GlAlert,
DevopsAdoptionAddDropdown,
DevopsAdoptionSection,
DevopsAdoptionOverview,
DevopsScore,
GlTabs,
GlTab,
......@@ -140,7 +142,10 @@ export default {
);
},
tabIndexValues() {
const tabs = this.$options.devopsAdoptionTableConfiguration.map((item) => item.tab);
const tabs = [
'overview',
...this.$options.devopsAdoptionTableConfiguration.map((item) => item.tab),
];
return this.isGroup ? tabs : [...tabs, 'devops-score'];
},
......@@ -295,6 +300,15 @@ export default {
<template>
<div>
<gl-tabs :value="selectedTab" @input="onTabChange">
<gl-tab data-testid="devops-overview-tab">
<template #title>{{ s__('DevopsReport|Overview') }}</template>
<devops-adoption-overview
:loading="isLoadingAdoptionData"
:data="devopsAdoptionEnabledNamespaces"
:timestamp="timestamp"
/>
</gl-tab>
<gl-tab
v-for="tab in $options.devopsAdoptionTableConfiguration"
:key="tab.title"
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
import {
DEVOPS_ADOPTION_TABLE_CONFIGURATION,
DEVOPS_ADOPTION_OVERALL_CONFIGURATION,
TABLE_HEADER_TEXT,
} from '../constants';
import DevopsAdoptionOverviewCard from './devops_adoption_overview_card.vue';
export default {
name: 'DevopsAdoptionOverview',
components: {
DevopsAdoptionOverviewCard,
GlLoadingIcon,
},
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
data: {
type: Object,
required: false,
default: () => ({}),
},
timestamp: {
type: String,
required: true,
},
},
computed: {
featuresData() {
return DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((item) => ({
...item,
featureMeta: item.cols.map((feature) => ({
title: feature.label,
adopted: this.data.nodes?.some((node) =>
node.latestSnapshot ? node.latestSnapshot[feature.key] : false,
),
})),
}));
},
overallData() {
return {
...DEVOPS_ADOPTION_OVERALL_CONFIGURATION,
featureMeta: this.featuresData.reduce(
(features, section) => [...features, ...section.featureMeta],
[],
),
displayMeta: false,
};
},
overviewData() {
return [this.overallData, ...this.featuresData];
},
headerText() {
return sprintf(TABLE_HEADER_TEXT, { timestamp: this.timestamp });
},
},
};
</script>
<template>
<gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
<div v-else data-testid="overview-container">
<p class="gl-text-gray-400 gl-my-3" data-testid="overview-container-header">{{ headerText }}</p>
<div
class="gl-display-flex gl-justify-content-space-between gl-flex-direction-column gl-md-flex-direction-row gl-mt-5"
>
<devops-adoption-overview-card
v-for="item in overviewData"
:key="item.title"
class="gl-mb-5"
:icon="item.icon"
:title="item.title"
:variant="item.variant"
:feature-meta="item.featureMeta"
:display-meta="item.displayMeta"
/>
</div>
</div>
</template>
<script>
import { GlIcon, GlProgressBar } from '@gitlab/ui';
import { sprintf } from '~/locale';
import {
DEVOPS_ADOPTION_FEATURES_ADOPTED_TEXT,
DEVOPS_ADOPTION_PROGRESS_BAR_HEIGHT,
} from '../constants';
import DevopsAdoptionTableCellFlag from './devops_adoption_table_cell_flag.vue';
export default {
name: 'DevopsAdoptionOverviewCard',
progressBarHeight: DEVOPS_ADOPTION_PROGRESS_BAR_HEIGHT,
components: {
GlIcon,
GlProgressBar,
DevopsAdoptionTableCellFlag,
},
props: {
icon: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
variant: {
type: String,
required: false,
default: 'primary',
},
featureMeta: {
type: Array,
required: false,
default: () => [],
},
displayMeta: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
featuresCount() {
return this.featureMeta.length;
},
adoptedCount() {
return this.featureMeta.filter((feature) => feature.adopted).length;
},
description() {
return sprintf(DEVOPS_ADOPTION_FEATURES_ADOPTED_TEXT, {
adoptedCount: this.adoptedCount,
featuresCount: this.featuresCount,
title: this.displayMeta ? this.title : '',
});
},
},
};
</script>
<template>
<div
class="devops-overview-card gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-md-mr-5 gl-mb-4"
>
<div class="gl-display-flex gl-align-items-center gl-mb-3" data-testid="card-title">
<gl-icon :name="icon" class="gl-mr-3 gl-text-gray-500" />
<span class="gl-font-md gl-font-weight-bold" data-testid="card-title-text">{{ title }}</span>
</div>
<gl-progress-bar
:value="adoptedCount"
:max="featuresCount"
class="gl-mb-2 gl-md-mr-5"
:variant="variant"
:height="$options.progressBarHeight"
/>
<div class="gl-text-gray-400 gl-mb-1" data-testid="card-description">{{ description }}</div>
<template v-if="displayMeta">
<div
v-for="feature in featureMeta"
:key="feature.title"
class="gl-display-flex gl-align-items-center gl-mt-2"
data-testid="card-meta-row"
>
<devops-adoption-table-cell-flag
:enabled="feature.adopted"
:variant="variant"
class="gl-mr-3"
/>
<span class="gl-text-gray-600 gl-font-sm" data-testid="card-meta-row-title">{{
feature.title
}}</span>
</div>
</template>
</div>
</template>
......@@ -65,15 +65,15 @@ export default {
<template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" />
<div v-else-if="hasSegmentsData" class="gl-mt-3">
<div class="gl-my-3" data-testid="tableHeader">
<span class="gl-text-gray-400">
<div class="gl-mb-3" data-testid="tableHeader">
<p class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.tableHeaderText">
<template #timestamp>{{ timestamp }}</template>
</gl-sprintf>
</span>
</p>
<devops-adoption-add-dropdown
class="gl-mt-4 gl-mb-3 gl-md-display-none"
class="gl-mb-3 gl-md-display-none"
:search-term="searchTerm"
:groups="disabledGroupNodes"
:is-loading-groups="isLoadingGroups"
......
......@@ -6,6 +6,8 @@ export const PER_PAGE = 20;
export const DEBOUNCE_DELAY = 500;
export const DEVOPS_ADOPTION_PROGRESS_BAR_HEIGHT = '8px';
export const DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID = 'devopsSegmentDeleteModal';
export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
......@@ -35,6 +37,10 @@ export const DEVOPS_ADOPTION_NO_RESULTS = s__('DevopsAdoption|No results…');
export const DEVOPS_ADOPTION_NO_SUB_GROUPS = s__('DevopsAdoption|This group has no sub-groups');
export const DEVOPS_ADOPTION_FEATURES_ADOPTED_TEXT = s__(
'DevopsAdoption|%{adoptedCount}/%{featuresCount} %{title} features adopted',
);
export const DEVOPS_ADOPTION_STRINGS = {
app: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__(
......@@ -100,23 +106,20 @@ export const DEVOPS_ADOPTION_SEGMENTS_TABLE_SORT_DESC_STORAGE_KEY =
export const DEVOPS_ADOPTION_GROUP_COL_LABEL = __('Group');
export const DEVOPS_ADOPTION_OVERALL_CONFIGURATION = {
title: s__('DevopsAdoption|Overall adoption'),
icon: 'tanuki',
variant: 'primary',
cols: [],
};
export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{
title: s__('DevopsAdoption|Dev'),
tab: 'dev',
icon: 'code',
variant: 'warning',
cols: [
{
key: 'issueOpened',
label: s__('DevopsAdoption|Issues'),
tooltip: s__('DevopsAdoption|At least one issue opened'),
testId: 'issuesCol',
},
{
key: 'mergeRequestOpened',
label: s__('DevopsAdoption|MRs'),
tooltip: s__('DevopsAdoption|At least one MR opened'),
testId: 'mrsCol',
},
{
key: 'mergeRequestApproved',
label: s__('DevopsAdoption|Approvals'),
......@@ -129,11 +132,25 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
tooltip: s__('DevopsAdoption|Code owners enabled for at least one project'),
testId: 'codeownersCol',
},
{
key: 'issueOpened',
label: s__('DevopsAdoption|Issues'),
tooltip: s__('DevopsAdoption|At least one issue opened'),
testId: 'issuesCol',
},
{
key: 'mergeRequestOpened',
label: s__('DevopsAdoption|MRs'),
tooltip: s__('DevopsAdoption|At least one MR opened'),
testId: 'mrsCol',
},
],
},
{
title: s__('DevopsAdoption|Sec'),
tab: 'sec',
icon: 'shield',
variant: 'info',
cols: [
{
key: 'securityScanSucceeded',
......@@ -146,12 +163,14 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{
title: s__('DevopsAdoption|Ops'),
tab: 'ops',
icon: 'rocket',
variant: 'success',
cols: [
{
key: 'runnerConfigured',
label: s__('DevopsAdoption|Runners'),
tooltip: s__('DevopsAdoption|Runner configured for project/group'),
testId: 'runnersCol',
key: 'deploySucceeded',
label: s__('DevopsAdoption|Deploys'),
tooltip: s__('DevopsAdoption|At least one deploy'),
testId: 'deploysCol',
},
{
key: 'pipelineSucceeded',
......@@ -160,10 +179,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
testId: 'pipelinesCol',
},
{
key: 'deploySucceeded',
label: s__('DevopsAdoption|Deploys'),
tooltip: s__('DevopsAdoption|At least one deploy'),
testId: 'deploysCol',
key: 'runnerConfigured',
label: s__('DevopsAdoption|Runners'),
tooltip: s__('DevopsAdoption|Runner configured for project/group'),
testId: 'runnersCol',
},
],
},
......
......@@ -25,7 +25,7 @@ module Resolvers
def preloads
{
rules: [{ rules: :oncall_schedule }]
rules: [:ordered_rules]
}
end
end
......
......@@ -22,7 +22,8 @@ module Types
field :rules, [Types::IncidentManagement::EscalationRuleType],
null: true,
description: 'Steps of the escalation policy.'
description: 'Steps of the escalation policy.',
method: :ordered_rules
end
end
end
......@@ -22,6 +22,10 @@ module Types
field :status, Types::IncidentManagement::EscalationRuleStatusEnum,
null: true,
description: 'The status required to prevent the rule from activating.'
def oncall_schedule
Gitlab::Graphql::Loaders::BatchModelLoader.new(::IncidentManagement::OncallSchedule, object.oncall_schedule_id).find
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
......
......@@ -6,6 +6,7 @@ module IncidentManagement
belongs_to :project
has_many :rules, class_name: 'EscalationRule', inverse_of: :policy, foreign_key: 'policy_id', index_errors: true
has_many :ordered_rules, -> { order(:elapsed_time_seconds, :status) }, class_name: 'EscalationRule', inverse_of: :policy, foreign_key: 'policy_id'
validates :project_id, uniqueness: { message: _('can only have one escalation policy') }, on: :create
validates :name, presence: true, uniqueness: { scope: [:project_id] }, length: { maximum: 72 }
......
......@@ -256,9 +256,8 @@ class License < ApplicationRecord
before_validation :reset_license, if: :data_changed?
after_create :reset_current
after_create :update_trial_setting
after_destroy :reset_current
after_commit :reset_current
after_commit :reset_future_dated, on: [:create, :destroy]
after_commit :reset_previous, on: [:create, :destroy]
......@@ -364,6 +363,13 @@ class License < ApplicationRecord
yield(current_license) if block_given?
end
def current_cloud_license?(key)
current_license = License.current
return false unless current_license&.cloud_license?
current_license.data == key
end
private
def load_future_dated
......
......@@ -17,11 +17,10 @@ module GitlabSubscriptions
return response unless response[:success]
license = License.new(data: response[:license_key], cloud: true, last_synced_at: Time.current)
license = find_or_initialize_cloud_license(response[:license_key])
license.last_synced_at = Time.current
if license.save
License.cloud.id_not_in(license.id).delete_all
{ success: true, license: license }
else
error(license.errors.full_messages)
......@@ -43,5 +42,11 @@ module GitlabSubscriptions
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
def find_or_initialize_cloud_license(license_key)
return License.current.reset if License.current_cloud_license?(license_key)
License.new(data: license_key, cloud: true)
end
end
end
......@@ -34,10 +34,11 @@ class SyncSeatLinkRequestWorker
private
def reset_license!(license_data)
License.transaction do
License.cloud.delete_all
License.create!(data: license_data, cloud: true, last_synced_at: Time.current)
def reset_license!(license_key)
if License.current_cloud_license?(license_key)
License.current.reset.touch(:last_synced_at)
else
License.create!(data: license_key, cloud: true, last_synced_at: Time.current)
end
rescue StandardError => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
......
......@@ -6,5 +6,9 @@ FactoryBot.define do
oncall_schedule { association :incident_management_oncall_schedule, project: policy.project }
status { IncidentManagement::EscalationRule.statuses[:acknowledged] }
elapsed_time_seconds { 5.minutes }
trait :resolved do
status { IncidentManagement::EscalationRule.statuses[:resolved] }
end
end
end
......@@ -54,16 +54,16 @@ RSpec.describe 'DevOps Report page', :js do
visit admin_dev_ops_report_path
within tabs_selector do
expect(page.all(:css, tab_item_selector).length).to be(5)
expect(page).to have_text 'Dev Sec Ops DevOps Score'
expect(page.all(:css, tab_item_selector).length).to be(6)
expect(page).to have_text 'Overview Dev Sec Ops DevOps Score'
end
end
it 'defaults to the Dev tab' do
it 'defaults to the Overview tab' do
visit admin_dev_ops_report_path
within tabs_selector do
expect(page).to have_selector active_tab_selector, text: 'Dev'
expect(page).to have_selector active_tab_selector, text: 'Overview'
end
end
......@@ -83,10 +83,12 @@ RSpec.describe 'DevOps Report page', :js do
it_behaves_like 'displays tab content', tab[:text]
end
it 'does not add the tab param when the Dev tab is selected' do
it 'does not add the tab param when the Overview tab is selected' do
visit admin_dev_ops_report_path
click_link 'Dev'
within tabs_selector do
click_link 'Overview'
end
expect(page).to have_current_path(admin_dev_ops_report_path)
end
......
......@@ -5,6 +5,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import DevopsAdoptionAddDropdown from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_add_dropdown.vue';
import DevopsAdoptionApp from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_app.vue';
import DevopsAdoptionOverview from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview.vue';
import DevopsAdoptionSection from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_section.vue';
import {
DEVOPS_ADOPTION_STRINGS,
......@@ -101,6 +102,7 @@ describe('DevopsAdoptionApp', () => {
}
const findDevopsScoreTab = () => wrapper.findByTestId('devops-score-tab');
const findOverviewTab = () => wrapper.findByTestId('devops-overview-tab');
afterEach(() => {
wrapper.destroy();
......@@ -142,7 +144,7 @@ describe('DevopsAdoptionApp', () => {
});
it('displays the error message and calls Sentry', () => {
const alert = wrapper.find(GlAlert);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
......@@ -239,11 +241,11 @@ describe('DevopsAdoptionApp', () => {
});
it('does not render the devops section', () => {
expect(wrapper.find(DevopsAdoptionSection).exists()).toBe(false);
expect(wrapper.findComponent(DevopsAdoptionSection).exists()).toBe(false);
});
it('displays the error message ', () => {
const alert = wrapper.find(GlAlert);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.addSegmentsError);
});
......@@ -270,11 +272,11 @@ describe('DevopsAdoptionApp', () => {
});
it('does not render the devops section', () => {
expect(wrapper.find(DevopsAdoptionSection).exists()).toBe(false);
expect(wrapper.findComponent(DevopsAdoptionSection).exists()).toBe(false);
});
it('displays the error message ', () => {
const alert = wrapper.find(GlAlert);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError);
});
......@@ -344,6 +346,16 @@ describe('DevopsAdoptionApp', () => {
};
const defaultDevopsAdoptionTabBehavior = () => {
describe('overview tab', () => {
it('displays the overview tab', () => {
expect(findOverviewTab().exists()).toBe(true);
});
it('displays the devops adoption overview component', () => {
expect(findOverviewTab().findComponent(DevopsAdoptionOverview).exists()).toBe(true);
});
});
describe('devops adoption tabs', () => {
it('displays the configured number of tabs', () => {
expect(wrapper.findAllByTestId('devops-adoption-tab')).toHaveLength(
......@@ -353,12 +365,15 @@ describe('DevopsAdoptionApp', () => {
it('displays the devops section component with the tab', () => {
expect(
wrapper.findByTestId('devops-adoption-tab').find(DevopsAdoptionSection).exists(),
wrapper
.findByTestId('devops-adoption-tab')
.findComponent(DevopsAdoptionSection)
.exists(),
).toBe(true);
});
it('displays the DevopsAdoptionAddDropdown as the last tab', () => {
expect(wrapper.find(DevopsAdoptionAddDropdown).exists()).toBe(true);
expect(wrapper.findComponent(DevopsAdoptionAddDropdown).exists()).toBe(true);
});
eventTrackingBehaviour('devops-adoption-tab', 'i_analytics_dev_ops_adoption');
......@@ -379,7 +394,7 @@ describe('DevopsAdoptionApp', () => {
});
it('displays the devops score component', () => {
expect(findDevopsScoreTab().find(DevopsScore).exists()).toBe(true);
expect(findDevopsScoreTab().findComponent(DevopsScore).exists()).toBe(true);
});
eventTrackingBehaviour('devops-score-tab', 'i_analytics_dev_ops_score');
......
import { GlIcon, GlProgressBar } from '@gitlab/ui';
import DevopsAdoptionOverviewCard from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_card.vue';
import DevopsAdoptionTableCellFlag from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_table_cell_flag.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { overallAdoptionData } from '../mock_data';
describe('DevopsAdoptionOverview', () => {
let wrapper;
const createComponent = (props) => {
wrapper = shallowMountExtended(DevopsAdoptionOverviewCard, {
propsData: {
...overallAdoptionData,
displayMeta: true,
...props,
},
});
};
describe('default state', () => {
beforeEach(() => {
createComponent();
});
describe('title', () => {
it('displays a icon', () => {
const icon = wrapper.findComponent(GlIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe(overallAdoptionData.icon);
});
it('displays the title text', () => {
const text = wrapper.findByTestId('card-title-text');
expect(text.exists()).toBe(true);
expect(text.text()).toBe(overallAdoptionData.title);
});
});
it('displays the progress bar', () => {
expect(wrapper.findComponent(GlProgressBar).exists()).toBe(true);
});
it('displays the description correctly', () => {
const text = wrapper.findByTestId('card-description');
expect(text.exists()).toBe(true);
expect(text.text()).toBe('3/8 Overall adoption features adopted');
});
describe('meta', () => {
it('displays the meta', () => {
expect(wrapper.findByTestId('card-meta-row').exists()).toBe(true);
});
it('displays the correct number of rows', () => {
expect(wrapper.findAllByTestId('card-meta-row')).toHaveLength(
overallAdoptionData.featureMeta.length,
);
});
describe('meta row', () => {
it('displays a cell flag component', () => {
expect(wrapper.findComponent(DevopsAdoptionTableCellFlag).exists()).toBe(true);
});
it('displays the feature title', () => {
expect(wrapper.findByTestId('card-meta-row-title').text()).toBe(
overallAdoptionData.featureMeta[0].title,
);
});
});
});
});
describe('when not displaying meta', () => {
beforeEach(() => {
createComponent({ displayMeta: false });
});
it('displays the description correctly', () => {
const text = wrapper.findByTestId('card-description');
expect(text.exists()).toBe(true);
expect(text.text()).toBe('3/8 features adopted');
});
it('does not display the meta', () => {
expect(wrapper.findByTestId('card-meta-row').exists()).toBe(false);
});
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import DevopsAdoptionOverview from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview.vue';
import DevopsAdoptionOverviewCard from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_card.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { devopsAdoptionNamespaceData, overallAdoptionData } from '../mock_data';
describe('DevopsAdoptionOverview', () => {
let wrapper;
const createComponent = (props) => {
wrapper = shallowMountExtended(DevopsAdoptionOverview, {
propsData: {
timestamp: '2020-10-31 23:59',
data: devopsAdoptionNamespaceData,
...props,
},
});
};
describe('default state', () => {
beforeEach(() => {
createComponent();
});
it('displays the overview container', () => {
expect(wrapper.findByTestId('overview-container').exists()).toBe(true);
});
describe('overview container', () => {
it('displays the header text', () => {
const text = wrapper.findByTestId('overview-container-header');
expect(text.exists()).toBe(true);
expect(text.text()).toBe(
'Feature adoption is based on usage in the previous calendar month. Last updated: 2020-10-31 23:59.',
);
});
it('displays the correct numnber of overview cards', () => {
expect(wrapper.findAllComponents(DevopsAdoptionOverviewCard)).toHaveLength(4);
});
it('passes the cards the correct data', () => {
expect(wrapper.findComponent(DevopsAdoptionOverviewCard).props()).toStrictEqual(
overallAdoptionData,
);
});
});
});
describe('loading', () => {
beforeEach(() => {
createComponent({ loading: true });
});
it('displays a loading icon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('does not display the overview container', () => {
expect(wrapper.findByTestId('overview-container').exists()).toBe(false);
});
});
});
......@@ -234,7 +234,7 @@ describe('DevopsAdoptionTable', () => {
await nextTick();
expect(findSortByLocalStorageSync().props('value')).toBe('issueOpened');
expect(findSortByLocalStorageSync().props('value')).toBe('mergeRequestApproved');
});
it('should update local storage when the sort direction changes', async () => {
......
......@@ -77,23 +77,23 @@ export const devopsAdoptionTableHeaders = [
},
{
index: 1,
label: 'Issues',
tooltip: 'At least one issue opened',
label: 'Approvals',
tooltip: 'At least one approval on an MR',
},
{
index: 2,
label: 'MRs',
tooltip: 'At least one MR opened',
label: 'Code owners',
tooltip: 'Code owners enabled for at least one project',
},
{
index: 3,
label: 'Approvals',
tooltip: 'At least one approval on an MR',
label: 'Issues',
tooltip: 'At least one issue opened',
},
{
index: 4,
label: 'Code owners',
tooltip: 'Code owners enabled for at least one project',
label: 'MRs',
tooltip: 'At least one MR opened',
},
{
index: 5,
......@@ -110,3 +110,44 @@ export const dataErrorMessage = 'Name already taken.';
export const genericDeleteErrorMessage =
'An error occurred while removing the group. Please try again.';
export const overallAdoptionData = {
displayMeta: false,
featureMeta: [
{
adopted: false,
title: 'Approvals',
},
{
adopted: false,
title: 'Code owners',
},
{
adopted: true,
title: 'Issues',
},
{
adopted: true,
title: 'MRs',
},
{
adopted: false,
title: 'Scanning',
},
{
adopted: false,
title: 'Deploys',
},
{
adopted: false,
title: 'Pipelines',
},
{
adopted: true,
title: 'Runners',
},
],
icon: 'tanuki',
title: 'Overall adoption',
variant: 'primary',
};
......@@ -12,6 +12,7 @@ RSpec.describe IncidentManagement::EscalationPolicy do
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:rules) }
it { is_expected.to have_many(:ordered_rules).order(elapsed_time_seconds: :asc, status: :asc) }
end
describe 'validations' do
......
......@@ -342,6 +342,46 @@ RSpec.describe License do
end
describe 'Callbacks' do
describe '#reset_current', :request_store do
def current_license_cached_value
License.cache.read(License::CACHE_KEY, License)
end
before do
License.current # Set cache up front
end
context 'when a license is created' do
it 'expires the current_license cached value' do
expect(current_license_cached_value).to be_present
create(:license)
expect(current_license_cached_value).to be_nil
end
end
context 'when a license is updated' do
it 'expires the current_license cached value' do
expect(current_license_cached_value).to be_present
License.last.update!(updated_at: Time.current)
expect(current_license_cached_value).to be_nil
end
end
context 'when a license is destroyed' do
it 'expires the current_license cached value' do
expect(current_license_cached_value).to be_present
License.last.destroy!
expect(current_license_cached_value).to be_nil
end
end
end
describe '#reset_future_dated', :request_store do
let!(:future_dated_license) { create(:license, data: create(:gitlab_license, starts_at: Date.current + 1.month).export) }
......@@ -767,6 +807,41 @@ RSpec.describe License do
end
end
end
describe '.current_cloud_license?' do
subject { described_class.current_cloud_license?(license_key) }
let(:license_key) { 'test-key' }
before do
allow(License).to receive(:current).and_return(current_license)
end
context 'when current license is not set' do
let(:current_license) { nil }
it { is_expected.to be(false) }
end
context 'when current license is not a cloud license' do
let(:current_license) { create(:license) }
it { is_expected.to be(false) }
end
context 'when current license is a cloud license but key does not match current' do
let(:current_license) { create_current_license(cloud_licensing_enabled: true) }
it { is_expected.to be(false) }
end
context 'when current license is a cloud license and key matches current' do
let(:current_license) { create_current_license(cloud_licensing_enabled: true) }
let(:license_key) { current_license.data }
it { is_expected.to be(true) }
end
end
end
describe "#data_filename" do
......
......@@ -56,6 +56,10 @@ RSpec.describe 'creating escalation policy' do
expect(first_rule['status']).to eq(create_params.dig(:rules, 0, :status))
end
include_examples 'correctly reorders escalation rule inputs' do
let(:variables) { params }
end
context 'errors' do
context 'user does not have permission' do
subject(:resolve) { post_graphql_mutation(mutation, current_user: create(:user)) }
......
......@@ -84,4 +84,8 @@ RSpec.describe 'Updating an escalation policy' do
]
)
end
include_examples 'correctly reorders escalation rule inputs' do
let(:resolve) { post_graphql_mutation(mutation, current_user: user) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting Incident Management escalation policies' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:policy) { create(:incident_management_escalation_policy, project: project) }
let_it_be(:rule) { policy.rules.first }
let_it_be(:schedule) { rule.oncall_schedule }
let(:params) { {} }
let(:fields) do
<<~QUERY
nodes {
id
rules {
id
elapsedTimeSeconds
status
oncallSchedule {
iid
name
}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('incidentManagementEscalationPolicies', {}, fields)
)
end
let(:escalation_policy_response) { graphql_data.dig('project', 'incidentManagementEscalationPolicies', 'nodes').first }
let(:escalation_rules_response) { escalation_policy_response['rules'] }
before do
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
project.add_reporter(current_user)
end
it 'includes expected data' do
post_graphql(query, current_user: current_user)
expect(escalation_rules_response).to eq([{
'id' => global_id(rule),
'elapsedTimeSeconds' => rule.elapsed_time_seconds, # 5 min
'status' => rule.status.upcase, # 'ACKNOWLEDGED'
'oncallSchedule' => {
'iid' => schedule.iid.to_s,
'name' => schedule.name
}
}])
end
context 'with multiple rules' do
let_it_be(:later_acknowledged_rule) { create(:incident_management_escalation_rule, policy: policy, elapsed_time_seconds: 10.minutes) }
let_it_be(:earlier_resolved_rule) { create(:incident_management_escalation_rule, :resolved, policy: policy, elapsed_time_seconds: 1.minute) }
let_it_be(:equivalent_resolved_rule) { create(:incident_management_escalation_rule, :resolved, policy: policy) }
it 'orders rules by time and status' do
post_graphql(query, current_user: current_user)
expect(escalation_rules_response.length).to eq(4)
expect(escalation_rules_response.map { |rule| rule['id'] }).to eq([
global_id(earlier_resolved_rule),
global_id(rule),
global_id(equivalent_resolved_rule),
global_id(later_acknowledged_rule)
])
end
end
it 'avoids N+1 queries' do
post_graphql(query, current_user: current_user)
base_count = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: current_user)
end
create(:incident_management_escalation_rule, policy: policy, elapsed_time_seconds: 1.hour)
expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(base_count)
end
private
def global_id(object)
object.to_global_id.to_s
end
end
......@@ -10,7 +10,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
create(:application_setting, cloud_license_enabled: cloud_license_enabled)
end
let_it_be(:license_key) { build(:gitlab_license).export }
let_it_be(:license_key) { build(:gitlab_license, :cloud).export }
let(:cloud_license_enabled) { true }
let(:activation_code) { 'activation_code' }
......@@ -35,7 +35,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
it 'persists license' do
freeze_time do
result = execute_service
created_license = License.last
created_license = License.current
expect(result).to eq({ success: true, license: created_license })
......@@ -47,12 +47,40 @@ RSpec.describe GitlabSubscriptions::ActivateService do
end
end
it 'deletes any existing cloud licenses' do
previous_1 = create(:license, cloud: true)
previous_2 = create(:license, cloud: true)
context 'when the current license key does not match the one returned from activation' do
it 'creates a new license' do
previous_license = create(:license, cloud: true, last_synced_at: 3.days.ago)
freeze_time do
expect { execute_service }.to change(License.cloud, :count).by(1)
current_license = License.current
expect(current_license.id).not_to eq(previous_license.id)
expect(current_license).to have_attributes(
data: license_key,
cloud: true,
last_synced_at: Time.current
)
end
end
end
expect { execute_service }.to change(License.cloud, :count).to(1)
expect(License.cloud).not_to include(previous_1, previous_2)
context 'when the current license key matches the one returned from activation' do
it 'reuses the current license and updates the last_synced_at' do
create(:license, cloud: true, last_synced_at: 3.days.ago)
current_license = create(:license, cloud: true, data: license_key, last_synced_at: 1.day.ago)
freeze_time do
expect { execute_service }.not_to change(License.cloud, :count)
expect(License.current).to have_attributes(
id: current_license.id,
data: license_key,
cloud: true,
last_synced_at: Time.current
)
end
end
end
context 'when persisting fails' do
......@@ -72,7 +100,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
expect(execute_service).to eq(customer_dot_response)
expect(License.last&.data).not_to eq(license_key)
expect(License.current&.data).not_to eq(license_key)
end
end
......
# frozen_string_literal: true
require 'spec_helper'
# Expected variables:
# schedule - IncidentManagement::OncallSchedule
# resolve - method which posts a mutation
# variables - attributes provided to the mutation
RSpec.shared_examples 'correctly reorders escalation rule inputs' do
context 'when rules are provided out of order' do
before do
variables[:rules] = [
{
oncallScheduleIid: schedule.iid,
elapsedTimeSeconds: 60,
status: 'RESOLVED'
},
{
oncallScheduleIid: schedule.iid,
elapsedTimeSeconds: 60,
status: 'ACKNOWLEDGED'
},
{
oncallScheduleIid: schedule.iid,
elapsedTimeSeconds: 0,
status: 'ACKNOWLEDGED'
}
]
end
it 'successfully creates the policy and reorders the rules' do
resolve
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors']).to be_empty
expect(pluck_from_rules_response('status')).to eq(%w(ACKNOWLEDGED ACKNOWLEDGED RESOLVED))
expect(pluck_from_rules_response('elapsedTimeSeconds')).to eq([0, 60, 60])
end
private
def pluck_from_rules_response(attribute)
mutation_response['escalationPolicy']['rules'].map { |rule| rule[attribute] }
end
end
end
......@@ -41,14 +41,13 @@ RSpec.describe SyncSeatLinkRequestWorker, type: :worker do
body: body,
headers: { content_type: 'application/json' }
)
allow(License).to receive(:current).and_return(current_license)
end
shared_examples 'successful license creation' do
it 'persists the new license' do
freeze_time do
expect { sync_seat_link }.to change(License, :count).by(1)
expect(License.last).to have_attributes(
expect(License.current).to have_attributes(
data: license_key,
cloud: true,
last_synced_at: Time.current
......@@ -58,53 +57,68 @@ RSpec.describe SyncSeatLinkRequestWorker, type: :worker do
end
context 'when there is no previous license' do
let(:current_license) { nil }
before do
License.delete_all
end
it_behaves_like 'successful license creation'
end
context 'when there is a previous license' do
context 'when it is a cloud license' do
let(:current_license) { create(:license, cloud: true) }
context 'when the current license key does not match the one returned from sync' do
it 'creates a new license' do
freeze_time do
current_license = create(:license, cloud: true, last_synced_at: 1.day.ago)
expect { sync_seat_link }.to change(License.cloud, :count).by(1)
new_current_license = License.current
expect(new_current_license).not_to eq(current_license.id)
expect(new_current_license).to have_attributes(
data: license_key,
cloud: true,
last_synced_at: Time.current
)
end
end
end
it 'persists the new license and deletes any existing cloud licenses' do
previous_license = create(:license, cloud: true)
context 'when the current license key matches the one returned from sync' do
it 'reuses the current license and updates the last_synced_at', :request_store do
freeze_time do
current_license = create(:license, cloud: true, data: license_key, last_synced_at: 1.day.ago)
expect { sync_seat_link }.to change(License.cloud, :count).to(1)
expect { sync_seat_link }.not_to change(License.cloud, :count)
expect(License.last).to have_attributes(data: license_key, cloud: true)
expect(License.cloud).not_to include(previous_license, current_license)
expect(License.current).to have_attributes(
id: current_license.id,
data: license_key,
cloud: true,
last_synced_at: Time.current
)
end
end
end
context 'when persisting fails' do
let(:license_key) { 'invalid-key' }
it 'does not delete the current license and logs error' do
current_license = License.current
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original
expect { sync_seat_link }.to raise_error
expect(License).to exist(current_license.id)
end
end
context 'when deleting fails' do
it 'does not create a new license and logs error' do
last_license = License.last
relation = instance_double(ActiveRecord::Relation)
allow(License).to receive(:cloud).and_return(relation)
allow(relation).to receive(:delete_all).and_raise
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original
expect { sync_seat_link }.to raise_error
expect(License.last).to eq(last_license)
expect(License).to exist(current_license.id)
end
end
end
context 'when it is not a cloud license' do
let(:current_license) { create(:license) }
before do
create(:license)
end
it_behaves_like 'successful license creation'
end
......
......@@ -43,7 +43,7 @@ module Gitlab
def missing_author_note
s_("GitHubImporter|*Merged by: %{author} at %{timestamp}*") % {
author: pull_request.merged_by.login,
author: pull_request.merged_by&.login || 'ghost',
timestamp: pull_request.merged_at
}
end
......
......@@ -11072,6 +11072,9 @@ msgstr ""
msgid "DevOps adoption"
msgstr ""
msgid "DevopsAdoption|%{adoptedCount}/%{featuresCount} %{title} features adopted"
msgstr ""
msgid "DevopsAdoption|Add Group"
msgstr ""
......@@ -11174,6 +11177,9 @@ msgstr ""
msgid "DevopsAdoption|Ops"
msgstr ""
msgid "DevopsAdoption|Overall adoption"
msgstr ""
msgid "DevopsAdoption|Pipelines"
msgstr ""
......@@ -11231,6 +11237,9 @@ msgstr ""
msgid "DevopsReport|Moderate"
msgstr ""
msgid "DevopsReport|Overview"
msgstr ""
msgid "DevopsReport|Score"
msgstr ""
......
......@@ -8,13 +8,14 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle
let(:project) { merge_request.project }
let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc }
let(:client_double) { double(user: double(id: 999, login: 'merger', email: 'merger@email.com')) }
let(:merger_user) { double(id: 999, login: 'merger') }
let(:pull_request) do
instance_double(
Gitlab::GithubImport::Representation::PullRequest,
iid: merge_request.iid,
merged_at: merged_at,
merged_by: double(id: 999, login: 'merger')
merged_by: merger_user
)
end
......@@ -48,4 +49,23 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle
expect(last_note.author).to eq(project.creator)
end
end
context 'when the merger user is not provided' do
let(:merger_user) { nil }
it 'adds a note referencing the merger user' do
expect { subject.execute }
.to change(Note, :count).by(1)
.and not_change(merge_request, :updated_at)
metrics = merge_request.metrics.reload
expect(metrics.merged_by).to be_nil
expect(metrics.merged_at).to eq(merged_at)
last_note = merge_request.notes.last
expect(last_note.note).to eq("*Merged by: ghost at 2017-01-01 12:00:00 UTC*")
expect(last_note.created_at).to eq(merged_at)
expect(last_note.author).to eq(project.creator)
end
end
end
......@@ -898,20 +898,20 @@
stylelint-declaration-strict-value "1.7.7"
stylelint-scss "3.18.0"
"@gitlab/svgs@1.201.0":
version "1.201.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.201.0.tgz#5b390ae497331ae18e3a913cc5557b03c1d91ca9"
integrity sha512-Tv5lAfgiWgSBepZ0bak+kPq7q1uhtNRkD3Ih9LAAPY70XWDKej3Yv3L5joGEn/Xny2vt8Z+EHXqGE/BRblsEQw==
"@gitlab/svgs@1.202.0":
version "1.202.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.202.0.tgz#dbfad291fc5f597f0d31ca6a694fa8e78af57847"
integrity sha512-gnTSeb0o5UuUaDdjg1uzvVgETnXNyu0ta7arAHWOmLjDfXINwF6COR+ItM3ZVD//qUFSwYaCozgrDR642QAL4Q==
"@gitlab/tributejs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@30.0.1":
version "30.0.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-30.0.1.tgz#34743506758b03c19d1f98e07e7e644313c701cf"
integrity sha512-sfq+cqHGq7QM3QVtH6yk5/vGMqYBAyaRWRDdCfOiggjXQJPxS49hkXUjVbVDOTQIysmSA9M29sjY3ZMa8hdyTg==
"@gitlab/ui@30.2.0":
version "30.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-30.2.0.tgz#eceec947f901cca9507a1ac8b3bd0031a5dcacf9"
integrity sha512-rYG3HyUHZQyum9+6OKvp45r9b9E/wzAl8rpFyIIZMg6a14JPfsGhdjXqycWlLxf3TAsbTD6MtjQm/z/I8J6V8g==
dependencies:
"@babel/standalone" "^7.0.0"
bootstrap-vue "2.18.1"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment