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 ...@@ -119,13 +119,12 @@ However, you can speed these cycles up somewhat by emptying the
to revert the change before merging! to revert the change before merging!
To enable the Dangerfile on another existing GitLab project, run the following 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. Create a [Project access tokens](../user/project/settings/project_access_tokens.md).
1. Add the `@gitlab-bot`'s `GITLAB_API_PRIVATE_TOKEN` value as a value for a new CI/CD 1. Add the token as a CI/CD project variable named `DANGER_GITLAB_API_TOKEN`.
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. for review.
## Current uses ## Current uses
......
...@@ -38,13 +38,14 @@ collected before this feature is available. ...@@ -38,13 +38,14 @@ collected before this feature is available.
## DevOps Adoption **(ULTIMATE SELF)** ## 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. > - [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 by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59267) in GitLab 14.0.
> - Enabled on GitLab.com. > - Enabled on GitLab.com.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#disable-or-enable-devops-adoption). **(ULTIMATE SELF)** > - 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 - Dev
- Issues - Issues
...@@ -67,7 +68,7 @@ DevOps Adoption allows you to: ...@@ -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. - 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. - 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 ### Disable or enable DevOps Adoption
......
...@@ -8,6 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -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). > - [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. > - [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: Prerequisites:
...@@ -38,7 +39,7 @@ With DevOps Adoption you can: ...@@ -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. - 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. - 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 ## Enable data processing
......
...@@ -22,6 +22,7 @@ import getGroupsQuery from '../graphql/queries/get_groups.query.graphql'; ...@@ -22,6 +22,7 @@ import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates'; import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates';
import { shouldPollTableData } from '../utils/helpers'; import { shouldPollTableData } from '../utils/helpers';
import DevopsAdoptionAddDropdown from './devops_adoption_add_dropdown.vue'; import DevopsAdoptionAddDropdown from './devops_adoption_add_dropdown.vue';
import DevopsAdoptionOverview from './devops_adoption_overview.vue';
import DevopsAdoptionSection from './devops_adoption_section.vue'; import DevopsAdoptionSection from './devops_adoption_section.vue';
export default { export default {
...@@ -30,6 +31,7 @@ export default { ...@@ -30,6 +31,7 @@ export default {
GlAlert, GlAlert,
DevopsAdoptionAddDropdown, DevopsAdoptionAddDropdown,
DevopsAdoptionSection, DevopsAdoptionSection,
DevopsAdoptionOverview,
DevopsScore, DevopsScore,
GlTabs, GlTabs,
GlTab, GlTab,
...@@ -140,7 +142,10 @@ export default { ...@@ -140,7 +142,10 @@ export default {
); );
}, },
tabIndexValues() { 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']; return this.isGroup ? tabs : [...tabs, 'devops-score'];
}, },
...@@ -295,6 +300,15 @@ export default { ...@@ -295,6 +300,15 @@ export default {
<template> <template>
<div> <div>
<gl-tabs :value="selectedTab" @input="onTabChange"> <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 <gl-tab
v-for="tab in $options.devopsAdoptionTableConfiguration" v-for="tab in $options.devopsAdoptionTableConfiguration"
:key="tab.title" :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 { ...@@ -65,15 +65,15 @@ export default {
<template> <template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" /> <gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" />
<div v-else-if="hasSegmentsData" class="gl-mt-3"> <div v-else-if="hasSegmentsData" class="gl-mt-3">
<div class="gl-my-3" data-testid="tableHeader"> <div class="gl-mb-3" data-testid="tableHeader">
<span class="gl-text-gray-400"> <p class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.tableHeaderText"> <gl-sprintf :message="$options.i18n.tableHeaderText">
<template #timestamp>{{ timestamp }}</template> <template #timestamp>{{ timestamp }}</template>
</gl-sprintf> </gl-sprintf>
</span> </p>
<devops-adoption-add-dropdown <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" :search-term="searchTerm"
:groups="disabledGroupNodes" :groups="disabledGroupNodes"
:is-loading-groups="isLoadingGroups" :is-loading-groups="isLoadingGroups"
......
...@@ -6,6 +6,8 @@ export const PER_PAGE = 20; ...@@ -6,6 +6,8 @@ export const PER_PAGE = 20;
export const DEBOUNCE_DELAY = 500; export const DEBOUNCE_DELAY = 500;
export const DEVOPS_ADOPTION_PROGRESS_BAR_HEIGHT = '8px';
export const DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID = 'devopsSegmentDeleteModal'; export const DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID = 'devopsSegmentDeleteModal';
export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM'; export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
...@@ -35,6 +37,10 @@ export const DEVOPS_ADOPTION_NO_RESULTS = s__('DevopsAdoption|No results…'); ...@@ -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_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 = { export const DEVOPS_ADOPTION_STRINGS = {
app: { app: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__( [DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__(
...@@ -100,23 +106,20 @@ export const DEVOPS_ADOPTION_SEGMENTS_TABLE_SORT_DESC_STORAGE_KEY = ...@@ -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_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 = [ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{ {
title: s__('DevopsAdoption|Dev'), title: s__('DevopsAdoption|Dev'),
tab: 'dev', tab: 'dev',
icon: 'code',
variant: 'warning',
cols: [ 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', key: 'mergeRequestApproved',
label: s__('DevopsAdoption|Approvals'), label: s__('DevopsAdoption|Approvals'),
...@@ -129,11 +132,25 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [ ...@@ -129,11 +132,25 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
tooltip: s__('DevopsAdoption|Code owners enabled for at least one project'), tooltip: s__('DevopsAdoption|Code owners enabled for at least one project'),
testId: 'codeownersCol', 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'), title: s__('DevopsAdoption|Sec'),
tab: 'sec', tab: 'sec',
icon: 'shield',
variant: 'info',
cols: [ cols: [
{ {
key: 'securityScanSucceeded', key: 'securityScanSucceeded',
...@@ -146,12 +163,14 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [ ...@@ -146,12 +163,14 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{ {
title: s__('DevopsAdoption|Ops'), title: s__('DevopsAdoption|Ops'),
tab: 'ops', tab: 'ops',
icon: 'rocket',
variant: 'success',
cols: [ cols: [
{ {
key: 'runnerConfigured', key: 'deploySucceeded',
label: s__('DevopsAdoption|Runners'), label: s__('DevopsAdoption|Deploys'),
tooltip: s__('DevopsAdoption|Runner configured for project/group'), tooltip: s__('DevopsAdoption|At least one deploy'),
testId: 'runnersCol', testId: 'deploysCol',
}, },
{ {
key: 'pipelineSucceeded', key: 'pipelineSucceeded',
...@@ -160,10 +179,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [ ...@@ -160,10 +179,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
testId: 'pipelinesCol', testId: 'pipelinesCol',
}, },
{ {
key: 'deploySucceeded', key: 'runnerConfigured',
label: s__('DevopsAdoption|Deploys'), label: s__('DevopsAdoption|Runners'),
tooltip: s__('DevopsAdoption|At least one deploy'), tooltip: s__('DevopsAdoption|Runner configured for project/group'),
testId: 'deploysCol', testId: 'runnersCol',
}, },
], ],
}, },
......
...@@ -25,7 +25,7 @@ module Resolvers ...@@ -25,7 +25,7 @@ module Resolvers
def preloads def preloads
{ {
rules: [{ rules: :oncall_schedule }] rules: [:ordered_rules]
} }
end end
end end
......
...@@ -22,7 +22,8 @@ module Types ...@@ -22,7 +22,8 @@ module Types
field :rules, [Types::IncidentManagement::EscalationRuleType], field :rules, [Types::IncidentManagement::EscalationRuleType],
null: true, null: true,
description: 'Steps of the escalation policy.' description: 'Steps of the escalation policy.',
method: :ordered_rules
end end
end end
end end
...@@ -22,6 +22,10 @@ module Types ...@@ -22,6 +22,10 @@ module Types
field :status, Types::IncidentManagement::EscalationRuleStatusEnum, field :status, Types::IncidentManagement::EscalationRuleStatusEnum,
null: true, null: true,
description: 'The status required to prevent the rule from activating.' 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 end
# rubocop: enable Graphql/AuthorizeTypes # rubocop: enable Graphql/AuthorizeTypes
end end
......
...@@ -6,6 +6,7 @@ module IncidentManagement ...@@ -6,6 +6,7 @@ module IncidentManagement
belongs_to :project belongs_to :project
has_many :rules, class_name: 'EscalationRule', inverse_of: :policy, foreign_key: 'policy_id', index_errors: true 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 :project_id, uniqueness: { message: _('can only have one escalation policy') }, on: :create
validates :name, presence: true, uniqueness: { scope: [:project_id] }, length: { maximum: 72 } validates :name, presence: true, uniqueness: { scope: [:project_id] }, length: { maximum: 72 }
......
...@@ -256,9 +256,8 @@ class License < ApplicationRecord ...@@ -256,9 +256,8 @@ class License < ApplicationRecord
before_validation :reset_license, if: :data_changed? before_validation :reset_license, if: :data_changed?
after_create :reset_current
after_create :update_trial_setting after_create :update_trial_setting
after_destroy :reset_current after_commit :reset_current
after_commit :reset_future_dated, on: [:create, :destroy] after_commit :reset_future_dated, on: [:create, :destroy]
after_commit :reset_previous, on: [:create, :destroy] after_commit :reset_previous, on: [:create, :destroy]
...@@ -364,6 +363,13 @@ class License < ApplicationRecord ...@@ -364,6 +363,13 @@ class License < ApplicationRecord
yield(current_license) if block_given? yield(current_license) if block_given?
end end
def current_cloud_license?(key)
current_license = License.current
return false unless current_license&.cloud_license?
current_license.data == key
end
private private
def load_future_dated def load_future_dated
......
...@@ -17,11 +17,10 @@ module GitlabSubscriptions ...@@ -17,11 +17,10 @@ module GitlabSubscriptions
return response unless response[:success] 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 if license.save
License.cloud.id_not_in(license.id).delete_all
{ success: true, license: license } { success: true, license: license }
else else
error(license.errors.full_messages) error(license.errors.full_messages)
...@@ -43,5 +42,11 @@ module GitlabSubscriptions ...@@ -43,5 +42,11 @@ module GitlabSubscriptions
def application_settings def application_settings
Gitlab::CurrentSettings.current_application_settings Gitlab::CurrentSettings.current_application_settings
end 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
end end
...@@ -34,10 +34,11 @@ class SyncSeatLinkRequestWorker ...@@ -34,10 +34,11 @@ class SyncSeatLinkRequestWorker
private private
def reset_license!(license_data) def reset_license!(license_key)
License.transaction do if License.current_cloud_license?(license_key)
License.cloud.delete_all License.current.reset.touch(:last_synced_at)
License.create!(data: license_data, cloud: true, last_synced_at: Time.current) else
License.create!(data: license_key, cloud: true, last_synced_at: Time.current)
end end
rescue StandardError => e rescue StandardError => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
......
...@@ -6,5 +6,9 @@ FactoryBot.define do ...@@ -6,5 +6,9 @@ FactoryBot.define do
oncall_schedule { association :incident_management_oncall_schedule, project: policy.project } oncall_schedule { association :incident_management_oncall_schedule, project: policy.project }
status { IncidentManagement::EscalationRule.statuses[:acknowledged] } status { IncidentManagement::EscalationRule.statuses[:acknowledged] }
elapsed_time_seconds { 5.minutes } elapsed_time_seconds { 5.minutes }
trait :resolved do
status { IncidentManagement::EscalationRule.statuses[:resolved] }
end
end end
end end
...@@ -54,16 +54,16 @@ RSpec.describe 'DevOps Report page', :js do ...@@ -54,16 +54,16 @@ RSpec.describe 'DevOps Report page', :js do
visit admin_dev_ops_report_path visit admin_dev_ops_report_path
within tabs_selector do within tabs_selector do
expect(page.all(:css, tab_item_selector).length).to be(5) expect(page.all(:css, tab_item_selector).length).to be(6)
expect(page).to have_text 'Dev Sec Ops DevOps Score' expect(page).to have_text 'Overview Dev Sec Ops DevOps Score'
end end
end end
it 'defaults to the Dev tab' do it 'defaults to the Overview tab' do
visit admin_dev_ops_report_path visit admin_dev_ops_report_path
within tabs_selector do 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
end end
...@@ -83,10 +83,12 @@ RSpec.describe 'DevOps Report page', :js do ...@@ -83,10 +83,12 @@ RSpec.describe 'DevOps Report page', :js do
it_behaves_like 'displays tab content', tab[:text] it_behaves_like 'displays tab content', tab[:text]
end 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 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) expect(page).to have_current_path(admin_dev_ops_report_path)
end end
......
...@@ -5,6 +5,7 @@ import Vue from 'vue'; ...@@ -5,6 +5,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import DevopsAdoptionAddDropdown from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_add_dropdown.vue'; 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 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 DevopsAdoptionSection from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_section.vue';
import { import {
DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_STRINGS,
...@@ -101,6 +102,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -101,6 +102,7 @@ describe('DevopsAdoptionApp', () => {
} }
const findDevopsScoreTab = () => wrapper.findByTestId('devops-score-tab'); const findDevopsScoreTab = () => wrapper.findByTestId('devops-score-tab');
const findOverviewTab = () => wrapper.findByTestId('devops-overview-tab');
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -142,7 +144,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -142,7 +144,7 @@ describe('DevopsAdoptionApp', () => {
}); });
it('displays the error message and calls Sentry', () => { 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.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError); expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error); expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
...@@ -239,11 +241,11 @@ describe('DevopsAdoptionApp', () => { ...@@ -239,11 +241,11 @@ describe('DevopsAdoptionApp', () => {
}); });
it('does not render the devops section', () => { 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 ', () => { it('displays the error message ', () => {
const alert = wrapper.find(GlAlert); const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.addSegmentsError); expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.addSegmentsError);
}); });
...@@ -270,11 +272,11 @@ describe('DevopsAdoptionApp', () => { ...@@ -270,11 +272,11 @@ describe('DevopsAdoptionApp', () => {
}); });
it('does not render the devops section', () => { 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 ', () => { it('displays the error message ', () => {
const alert = wrapper.find(GlAlert); const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError); expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError);
}); });
...@@ -344,6 +346,16 @@ describe('DevopsAdoptionApp', () => { ...@@ -344,6 +346,16 @@ describe('DevopsAdoptionApp', () => {
}; };
const defaultDevopsAdoptionTabBehavior = () => { 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', () => { describe('devops adoption tabs', () => {
it('displays the configured number of tabs', () => { it('displays the configured number of tabs', () => {
expect(wrapper.findAllByTestId('devops-adoption-tab')).toHaveLength( expect(wrapper.findAllByTestId('devops-adoption-tab')).toHaveLength(
...@@ -353,12 +365,15 @@ describe('DevopsAdoptionApp', () => { ...@@ -353,12 +365,15 @@ describe('DevopsAdoptionApp', () => {
it('displays the devops section component with the tab', () => { it('displays the devops section component with the tab', () => {
expect( expect(
wrapper.findByTestId('devops-adoption-tab').find(DevopsAdoptionSection).exists(), wrapper
.findByTestId('devops-adoption-tab')
.findComponent(DevopsAdoptionSection)
.exists(),
).toBe(true); ).toBe(true);
}); });
it('displays the DevopsAdoptionAddDropdown as the last tab', () => { 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'); eventTrackingBehaviour('devops-adoption-tab', 'i_analytics_dev_ops_adoption');
...@@ -379,7 +394,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -379,7 +394,7 @@ describe('DevopsAdoptionApp', () => {
}); });
it('displays the devops score component', () => { 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'); 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', () => { ...@@ -234,7 +234,7 @@ describe('DevopsAdoptionTable', () => {
await nextTick(); 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 () => { it('should update local storage when the sort direction changes', async () => {
......
...@@ -77,23 +77,23 @@ export const devopsAdoptionTableHeaders = [ ...@@ -77,23 +77,23 @@ export const devopsAdoptionTableHeaders = [
}, },
{ {
index: 1, index: 1,
label: 'Issues', label: 'Approvals',
tooltip: 'At least one issue opened', tooltip: 'At least one approval on an MR',
}, },
{ {
index: 2, index: 2,
label: 'MRs', label: 'Code owners',
tooltip: 'At least one MR opened', tooltip: 'Code owners enabled for at least one project',
}, },
{ {
index: 3, index: 3,
label: 'Approvals', label: 'Issues',
tooltip: 'At least one approval on an MR', tooltip: 'At least one issue opened',
}, },
{ {
index: 4, index: 4,
label: 'Code owners', label: 'MRs',
tooltip: 'Code owners enabled for at least one project', tooltip: 'At least one MR opened',
}, },
{ {
index: 5, index: 5,
...@@ -110,3 +110,44 @@ export const dataErrorMessage = 'Name already taken.'; ...@@ -110,3 +110,44 @@ export const dataErrorMessage = 'Name already taken.';
export const genericDeleteErrorMessage = export const genericDeleteErrorMessage =
'An error occurred while removing the group. Please try again.'; '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 ...@@ -12,6 +12,7 @@ RSpec.describe IncidentManagement::EscalationPolicy do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:rules) } it { is_expected.to have_many(:rules) }
it { is_expected.to have_many(:ordered_rules).order(elapsed_time_seconds: :asc, status: :asc) }
end end
describe 'validations' do describe 'validations' do
......
...@@ -342,6 +342,46 @@ RSpec.describe License do ...@@ -342,6 +342,46 @@ RSpec.describe License do
end end
describe 'Callbacks' do 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 describe '#reset_future_dated', :request_store do
let!(:future_dated_license) { create(:license, data: create(:gitlab_license, starts_at: Date.current + 1.month).export) } 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 ...@@ -767,6 +807,41 @@ RSpec.describe License do
end end
end 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 end
describe "#data_filename" do describe "#data_filename" do
......
...@@ -56,6 +56,10 @@ RSpec.describe 'creating escalation policy' do ...@@ -56,6 +56,10 @@ RSpec.describe 'creating escalation policy' do
expect(first_rule['status']).to eq(create_params.dig(:rules, 0, :status)) expect(first_rule['status']).to eq(create_params.dig(:rules, 0, :status))
end end
include_examples 'correctly reorders escalation rule inputs' do
let(:variables) { params }
end
context 'errors' do context 'errors' do
context 'user does not have permission' do context 'user does not have permission' do
subject(:resolve) { post_graphql_mutation(mutation, current_user: create(:user)) } subject(:resolve) { post_graphql_mutation(mutation, current_user: create(:user)) }
......
...@@ -84,4 +84,8 @@ RSpec.describe 'Updating an escalation policy' do ...@@ -84,4 +84,8 @@ RSpec.describe 'Updating an escalation policy' do
] ]
) )
end end
include_examples 'correctly reorders escalation rule inputs' do
let(:resolve) { post_graphql_mutation(mutation, current_user: user) }
end
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 ...@@ -10,7 +10,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
create(:application_setting, cloud_license_enabled: cloud_license_enabled) create(:application_setting, cloud_license_enabled: cloud_license_enabled)
end 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(:cloud_license_enabled) { true }
let(:activation_code) { 'activation_code' } let(:activation_code) { 'activation_code' }
...@@ -35,7 +35,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do ...@@ -35,7 +35,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
it 'persists license' do it 'persists license' do
freeze_time do freeze_time do
result = execute_service result = execute_service
created_license = License.last created_license = License.current
expect(result).to eq({ success: true, license: created_license }) expect(result).to eq({ success: true, license: created_license })
...@@ -47,12 +47,40 @@ RSpec.describe GitlabSubscriptions::ActivateService do ...@@ -47,12 +47,40 @@ RSpec.describe GitlabSubscriptions::ActivateService do
end end
end end
it 'deletes any existing cloud licenses' do context 'when the current license key does not match the one returned from activation' do
previous_1 = create(:license, cloud: true) it 'creates a new license' do
previous_2 = create(:license, cloud: true) 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) context 'when the current license key matches the one returned from activation' do
expect(License.cloud).not_to include(previous_1, previous_2) 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 end
context 'when persisting fails' do context 'when persisting fails' do
...@@ -72,7 +100,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do ...@@ -72,7 +100,7 @@ RSpec.describe GitlabSubscriptions::ActivateService do
expect(execute_service).to eq(customer_dot_response) 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
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 ...@@ -41,14 +41,13 @@ RSpec.describe SyncSeatLinkRequestWorker, type: :worker do
body: body, body: body,
headers: { content_type: 'application/json' } headers: { content_type: 'application/json' }
) )
allow(License).to receive(:current).and_return(current_license)
end end
shared_examples 'successful license creation' do shared_examples 'successful license creation' do
it 'persists the new license' do it 'persists the new license' do
freeze_time do freeze_time do
expect { sync_seat_link }.to change(License, :count).by(1) 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, data: license_key,
cloud: true, cloud: true,
last_synced_at: Time.current last_synced_at: Time.current
...@@ -58,53 +57,68 @@ RSpec.describe SyncSeatLinkRequestWorker, type: :worker do ...@@ -58,53 +57,68 @@ RSpec.describe SyncSeatLinkRequestWorker, type: :worker do
end end
context 'when there is no previous license' do context 'when there is no previous license' do
let(:current_license) { nil } before do
License.delete_all
end
it_behaves_like 'successful license creation' it_behaves_like 'successful license creation'
end end
context 'when there is a previous license' do context 'when there is a previous license' do
context 'when it is a cloud 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 context 'when the current license key matches the one returned from sync' do
previous_license = create(:license, cloud: true) 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.current).to have_attributes(
expect(License.cloud).not_to include(previous_license, current_license) id: current_license.id,
data: license_key,
cloud: true,
last_synced_at: Time.current
)
end
end
end end
context 'when persisting fails' do context 'when persisting fails' do
let(:license_key) { 'invalid-key' } let(:license_key) { 'invalid-key' }
it 'does not delete the current license and logs error' do 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(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original
expect { sync_seat_link }.to raise_error expect { sync_seat_link }.to raise_error
expect(License).to exist(current_license.id)
end
end
context 'when deleting fails' do expect(License).to exist(current_license.id)
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)
end end
end end
end end
context 'when it is not a cloud license' do 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' it_behaves_like 'successful license creation'
end end
......
...@@ -43,7 +43,7 @@ module Gitlab ...@@ -43,7 +43,7 @@ module Gitlab
def missing_author_note def missing_author_note
s_("GitHubImporter|*Merged by: %{author} at %{timestamp}*") % { 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 timestamp: pull_request.merged_at
} }
end end
......
...@@ -11072,6 +11072,9 @@ msgstr "" ...@@ -11072,6 +11072,9 @@ msgstr ""
msgid "DevOps adoption" msgid "DevOps adoption"
msgstr "" msgstr ""
msgid "DevopsAdoption|%{adoptedCount}/%{featuresCount} %{title} features adopted"
msgstr ""
msgid "DevopsAdoption|Add Group" msgid "DevopsAdoption|Add Group"
msgstr "" msgstr ""
...@@ -11174,6 +11177,9 @@ msgstr "" ...@@ -11174,6 +11177,9 @@ msgstr ""
msgid "DevopsAdoption|Ops" msgid "DevopsAdoption|Ops"
msgstr "" msgstr ""
msgid "DevopsAdoption|Overall adoption"
msgstr ""
msgid "DevopsAdoption|Pipelines" msgid "DevopsAdoption|Pipelines"
msgstr "" msgstr ""
...@@ -11231,6 +11237,9 @@ msgstr "" ...@@ -11231,6 +11237,9 @@ msgstr ""
msgid "DevopsReport|Moderate" msgid "DevopsReport|Moderate"
msgstr "" msgstr ""
msgid "DevopsReport|Overview"
msgstr ""
msgid "DevopsReport|Score" msgid "DevopsReport|Score"
msgstr "" msgstr ""
......
...@@ -8,13 +8,14 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle ...@@ -8,13 +8,14 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle
let(:project) { merge_request.project } let(:project) { merge_request.project }
let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc } 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(: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 let(:pull_request) do
instance_double( instance_double(
Gitlab::GithubImport::Representation::PullRequest, Gitlab::GithubImport::Representation::PullRequest,
iid: merge_request.iid, iid: merge_request.iid,
merged_at: merged_at, merged_at: merged_at,
merged_by: double(id: 999, login: 'merger') merged_by: merger_user
) )
end end
...@@ -48,4 +49,23 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle ...@@ -48,4 +49,23 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :cle
expect(last_note.author).to eq(project.creator) expect(last_note.author).to eq(project.creator)
end end
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 end
...@@ -898,20 +898,20 @@ ...@@ -898,20 +898,20 @@
stylelint-declaration-strict-value "1.7.7" stylelint-declaration-strict-value "1.7.7"
stylelint-scss "3.18.0" stylelint-scss "3.18.0"
"@gitlab/svgs@1.201.0": "@gitlab/svgs@1.202.0":
version "1.201.0" version "1.202.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.201.0.tgz#5b390ae497331ae18e3a913cc5557b03c1d91ca9" resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.202.0.tgz#dbfad291fc5f597f0d31ca6a694fa8e78af57847"
integrity sha512-Tv5lAfgiWgSBepZ0bak+kPq7q1uhtNRkD3Ih9LAAPY70XWDKej3Yv3L5joGEn/Xny2vt8Z+EHXqGE/BRblsEQw== integrity sha512-gnTSeb0o5UuUaDdjg1uzvVgETnXNyu0ta7arAHWOmLjDfXINwF6COR+ItM3ZVD//qUFSwYaCozgrDR642QAL4Q==
"@gitlab/tributejs@1.0.0": "@gitlab/tributejs@1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@30.0.1": "@gitlab/ui@30.2.0":
version "30.0.1" version "30.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-30.0.1.tgz#34743506758b03c19d1f98e07e7e644313c701cf" resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-30.2.0.tgz#eceec947f901cca9507a1ac8b3bd0031a5dcacf9"
integrity sha512-sfq+cqHGq7QM3QVtH6yk5/vGMqYBAyaRWRDdCfOiggjXQJPxS49hkXUjVbVDOTQIysmSA9M29sjY3ZMa8hdyTg== integrity sha512-rYG3HyUHZQyum9+6OKvp45r9b9E/wzAl8rpFyIIZMg6a14JPfsGhdjXqycWlLxf3TAsbTD6MtjQm/z/I8J6V8g==
dependencies: dependencies:
"@babel/standalone" "^7.0.0" "@babel/standalone" "^7.0.0"
bootstrap-vue "2.18.1" 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