summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-28 15:09:44 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-28 15:09:44 +0000
commit34b3acb5a3a9b21490e45b81b81dca600b66521c (patch)
tree81deb74283f931cdbf65b8878b41085b0213a9e6
parenteffda22b3e6367cefd12666463b8409bf7e24cef (diff)
downloadgitlab-ce-34b3acb5a3a9b21490e45b81b81dca600b66521c.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue117
-rw-r--r--app/assets/javascripts/alert_management/components/alert_summary_row.vue18
-rw-r--r--app/assets/javascripts/api.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue224
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue38
-rw-r--r--app/assets/javascripts/invite_members/event_hub.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js25
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js20
-rw-r--r--app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql8
-rw-r--r--app/assets/javascripts/registry/settings/graphql/index.js14
-rw-r--r--app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql10
-rw-r--r--app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql9
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js22
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js9
-rw-r--r--app/assets/javascripts/registry/shared/constants.js24
-rw-r--r--app/assets/javascripts/registry/shared/utils.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue18
-rw-r--r--app/helpers/invite_members_helper.rb7
-rw-r--r--app/models/ci/build.rb18
-rw-r--r--app/models/ci/build_trace_chunk.rb22
-rw-r--r--app/models/ci/build_trace_chunks/database.rb2
-rw-r--r--app/models/concerns/checksummable.rb8
-rw-r--r--app/models/iteration.rb16
-rw-r--r--app/services/ci/update_build_state_service.rb114
-rw-r--r--app/views/groups/_invite_members_modal.html.haml6
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml3
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/projects/registry/settings/_index.haml1
-rw-r--r--changelogs/unreleased/250355-iterations-overlap-validation-bug.yml5
-rw-r--r--changelogs/unreleased/feature-gb-validate-build-traces.yml5
-rw-r--r--config/feature_flags/development/ci_job_heartbeats_runner.yml7
-rw-r--r--config/feature_flags/development/invite_members_group_modal.yml7
-rw-r--r--doc/development/database_review.md4
-rw-r--r--doc/user/gitlab_com/index.md8
-rw-r--r--doc/user/project/import/github.md33
-rw-r--r--lib/api/helpers/runner.rb4
-rw-r--r--lib/gitlab/ci/features.rb4
-rw-r--r--lib/gitlab/ci/trace.rb7
-rw-r--r--lib/gitlab/ci/trace/checksum.rb81
-rw-r--r--lib/gitlab/ci/trace/metrics.rb3
-rw-r--r--lib/gitlab/exclusive_lease_helpers.rb2
-rw-r--r--lib/gitlab/metrics/requests_rack_middleware.rb8
-rw-r--r--locale/gitlab.pot50
-rw-r--r--package.json4
-rw-r--r--spec/factories/ci/build_trace_chunks.rb13
-rw-r--r--spec/features/groups/navbar_spec.rb8
-rw-r--r--spec/frontend/alert_management/components/alert_details_spec.js98
-rw-r--r--spec/frontend/alert_management/components/alert_summary_row_spec.js40
-rw-r--r--spec/frontend/helpers/vue_test_utils_helper.js7
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js115
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js58
-rw-r--r--spec/frontend/registry/settings/graphql/cache_updated_spec.js56
-rw-r--r--spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap101
-rw-r--r--spec/frontend/registry/shared/utils_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js (renamed from spec/frontend/vue_shared/components/alert_detail_table_spec.js)8
-rw-r--r--spec/lib/gitlab/ci/trace/checksum_spec.rb121
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb9
-rw-r--r--spec/lib/gitlab/exclusive_lease_helpers_spec.rb15
-rw-r--r--spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb88
-rw-r--r--spec/models/ci/build_spec.rb20
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb58
-rw-r--r--spec/models/concerns/checksummable_spec.rb16
-rw-r--r--spec/models/iteration_spec.rb45
-rw-r--r--spec/models/member_spec.rb10
-rw-r--r--spec/models/snippet_repository_spec.rb34
-rw-r--r--spec/requests/api/ci/runner/jobs_put_spec.rb14
-rw-r--r--spec/services/ci/update_build_state_service_spec.rb68
-rw-r--r--spec/support/shared_examples/features/navbar_shared_examples.rb6
-rw-r--r--yarn.lock18
73 files changed, 1770 insertions, 314 deletions
diff --git a/Gemfile b/Gemfile
index 0433bae2c4d..6defbba7461 100644
--- a/Gemfile
+++ b/Gemfile
@@ -272,7 +272,7 @@ gem 'licensee', '~> 8.9'
gem 'ace-rails-ap', '~> 4.1.0'
# Detect and convert string character encoding
-gem 'charlock_holmes', '~> 0.7.5'
+gem 'charlock_holmes', '~> 0.7.7'
# Detect mime content type from content
gem 'mimemagic', '~> 0.3.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index 0057b46f06e..e9d309545cf 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -167,7 +167,7 @@ GEM
mime-types (>= 1.16)
cbor (0.5.9.6)
character_set (1.4.0)
- charlock_holmes (0.7.6)
+ charlock_holmes (0.7.7)
childprocess (3.0.0)
chunky_png (1.3.5)
citrus (3.0.2)
@@ -1270,7 +1270,7 @@ DEPENDENCIES
capybara (~> 3.33.0)
capybara-screenshot (~> 1.0.22)
carrierwave (~> 1.3)
- charlock_holmes (~> 0.7.5)
+ charlock_holmes (~> 0.7.7)
commonmarker (~> 0.20)
concurrent-ruby (~> 1.1)
connection_pool (~> 2.0)
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index c6605452616..beb636ee8fa 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -1,15 +1,16 @@
<script>
-/* eslint-disable vue/no-v-html */
import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlBadge,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTabs,
GlTab,
GlButton,
+ GlSafeHtmlDirective,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import alertQuery from '../graphql/queries/details.query.graphql';
@@ -28,6 +29,7 @@ import SystemNote from './system_notes/system_note.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertMetrics from './alert_metrics.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import AlertSummaryRow from './alert_summary_row.vue';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
@@ -39,6 +41,9 @@ export default {
reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
severityLabels: ALERTS_SEVERITY_LABELS,
tabsConfig: [
{
@@ -56,9 +61,11 @@ export default {
],
components: {
AlertDetailsTable,
+ AlertSummaryRow,
GlBadge,
GlAlert,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTab,
@@ -211,7 +218,7 @@ export default {
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
- <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
+ <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
<gl-alert
v-if="createIncidentError"
@@ -283,54 +290,66 @@ export default {
</div>
<gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs">
<gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title">
- <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Severity') }}:
- </div>
- <div class="gl-pl-2" data-testid="severity">
- <span>
- <gl-icon
- class="gl-vertical-align-middle"
- :size="12"
- :name="`severity-${alert.severity.toLowerCase()}`"
- :class="`icon-${alert.severity.toLowerCase()}`"
- />
- </span>
+ <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`">
+ <span data-testid="severity">
+ <gl-icon
+ class="gl-vertical-align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
{{ $options.severityLabels[alert.severity] }}
- </div>
- </div>
- <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Start time') }}:
- </div>
- <div class="gl-pl-2">
- <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
- </div>
- </div>
- <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Events') }}:
- </div>
- <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div>
- </div>
- <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Tool') }}:
- </div>
- <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div>
- </div>
- <div v-if="alert.service" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Service') }}:
- </div>
- <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div>
- </div>
- <div v-if="alert.runbook" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Runbook') }}:
- </div>
- <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div>
- </div>
+ </span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.environment"
+ :label="`${s__('AlertManagement|Environment')}:`"
+ >
+ <gl-link
+ v-if="alert.environmentUrl"
+ class="gl-display-inline-block"
+ data-testid="environmentUrl"
+ :href="alert.environmentUrl"
+ target="_blank"
+ >
+ {{ alert.environment }}
+ </gl-link>
+ <span v-else data-testid="environment">{{ alert.environment }}</span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.startedAt"
+ :label="`${s__('AlertManagement|Start time')}:`"
+ >
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.eventCount"
+ :label="`${s__('AlertManagement|Events')}:`"
+ data-testid="eventCount"
+ >
+ {{ alert.eventCount }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.monitoringTool"
+ :label="`${s__('AlertManagement|Tool')}:`"
+ data-testid="monitoringTool"
+ >
+ {{ alert.monitoringTool }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.service"
+ :label="`${s__('AlertManagement|Service')}:`"
+ data-testid="service"
+ >
+ {{ alert.service }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.runbook"
+ :label="`${s__('AlertManagement|Runbook')}:`"
+ data-testid="runbook"
+ >
+ {{ alert.runbook }}
+ </alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
<gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title">
diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
new file mode 100644
index 00000000000..13835b7e2fa
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div>
+ <div class="gl-pl-2">
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index e77120fd12a..4bbb30450ff 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -112,6 +112,12 @@ const Api = {
});
},
+ inviteGroupMember(id, data) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, data);
+ },
+
groupMilestones(id, options) {
const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id));
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index f6bad5dce41..4cccabca28b 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -36,12 +36,6 @@ export default () => {
'stage-review-component': stageReviewComponent,
'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent,
- GroupsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'),
- ProjectsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
- DateRangeDropdown: () =>
- import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
},
data() {
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
new file mode 100644
index 00000000000..d2ea14a658b
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -0,0 +1,224 @@
+<script>
+import {
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+} from '@gitlab/ui';
+import eventHub from '../event_hub';
+import { s__, sprintf } from '~/locale';
+import Api from '~/api';
+
+export default {
+ name: 'InviteMembersModal',
+ components: {
+ GlDatepicker,
+ GlLink,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: String,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ visible: true,
+ modalId: 'invite-members-modal',
+ selectedAccessLevel: this.defaultAccessLevel,
+ newUsersToInvite: '',
+ selectedDate: undefined,
+ };
+ },
+ computed: {
+ introText() {
+ return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), {
+ group_name: this.groupName,
+ });
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.newUsersToInvite = '';
+ },
+ };
+ },
+ postData() {
+ return {
+ user_id: this.newUsersToInvite,
+ access_level: this.selectedAccessLevel,
+ expires_at: this.selectedDate,
+ format: 'json',
+ };
+ },
+ selectedRoleName() {
+ return Object.keys(this.accessLevels).find(
+ key => this.accessLevels[key] === Number(this.selectedAccessLevel),
+ );
+ },
+ },
+ mounted() {
+ eventHub.$on('openModal', this.openModal);
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ closeModal() {
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ sendInvite() {
+ this.submitForm(this.postData);
+ this.closeModal();
+ },
+ cancelInvite() {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.selectedDate = undefined;
+ this.newUsersToInvite = '';
+ this.closeModal();
+ },
+ changeSelectedItem(item) {
+ this.selectedAccessLevel = item;
+ },
+ submitForm(formData) {
+ return Api.inviteGroupMember(this.groupId, formData)
+ .then(() => {
+ this.showToastMessageSuccess();
+ })
+ .catch(error => {
+ this.showToastMessageError(error);
+ });
+ },
+ showToastMessageSuccess() {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ },
+ showToastMessageError(error) {
+ const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
+
+ this.$toast.show(message, this.toastOptions);
+ },
+ },
+ labels: {
+ modalTitle: s__('InviteMembersModal|Invite team members'),
+ userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
+ userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
+ accessLevel: s__('InviteMembersModal|Choose a role permission'),
+ accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
+ toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
+ toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
+ readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
+ inviteButtonText: s__('InviteMembersModal|Invite'),
+ cancelButtonText: s__('InviteMembersModal|Cancel'),
+ },
+};
+</script>
+<template>
+ <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
+ <div class="gl-ml-5 gl-mr-5">
+ <div>{{ introText }}</div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
+ <div class="gl-mt-2">
+ <gl-search-box-by-type
+ v-model="newUsersToInvite"
+ :placeholder="$options.labels.userPlaceholder"
+ type="text"
+ autocomplete="off"
+ autocorrect="off"
+ autocapitalize="off"
+ spellcheck="false"
+ />
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ menu-class="dropdown-menu-selectable"
+ class="gl-shadow-none gl-w-full"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-mt-2">
+ <gl-sprintf :message="$options.labels.readMoreText">
+ <template #link="{content}">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{
+ $options.labels.accessExpireDate
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="new Date()"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input
+ class="gl-w-full"
+ :value="formattedDate"
+ :placeholder="__(`YYYY-MM-DD`)"
+ />
+ </template>
+ </gl-datepicker>
+ </div>
+ </div>
+
+ <template #modal-footer>
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
+ <gl-button ref="cancelButton" @click="cancelInvite">
+ {{ $options.labels.cancelButtonText }}
+ </gl-button>
+ <div class="gl-mr-3"></div>
+ <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
+ $options.labels.inviteButtonText
+ }}</gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
new file mode 100644
index 00000000000..d133e3655e3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('InviteMembers|Invite team members'),
+ },
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link @click="openModal">
+ <div v-if="icon" class="nav-icon-container">
+ <gl-icon :size="16" :name="icon" />
+ </div>
+ <span class="nav-item-name"> {{ displayText }} </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/invite_members/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
new file mode 100644
index 00000000000..92aa3187fc3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+Vue.use(GlToast);
+
+export default function initInviteMembersModal() {
+ const el = document.querySelector('.js-invite-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersModal, {
+ props: {
+ ...el.dataset,
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ groupName: el.dataset.groupName.toUpperCase(),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
new file mode 100644
index 00000000000..bee4f1c0f72
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+export default function initInviteMembersTrigger() {
+ const el = document.querySelector('.js-invite-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
new file mode 100644
index 00000000000..224e0ed9472
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
@@ -0,0 +1,8 @@
+fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
+ cadence
+ enabled
+ keepN
+ nameRegex
+ nameRegexKeep
+ olderThan
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
new file mode 100644
index 00000000000..c40cd115ab0
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) {
+ updateContainerExpirationPolicy(input: $input) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
new file mode 100644
index 00000000000..c171be0ad07
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+query getProjectExpirationPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
new file mode 100644
index 00000000000..88067d52b51
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -0,0 +1,22 @@
+import { produce } from 'immer';
+import expirationPolicyQuery from '../queries/get_expiration_policy.graphql';
+
+export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => {
+ const queryAndParams = {
+ query: expirationPolicyQuery,
+ variables: { projectPath },
+ };
+ const sourceData = client.readQuery(queryAndParams);
+
+ const data = produce(sourceData, draftState => {
+ // eslint-disable-next-line no-param-reassign
+ draftState.project.containerExpirationPolicy = {
+ ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
+ };
+ });
+
+ client.writeQuery({
+ ...queryAndParams,
+ data,
+ });
+};
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index a318aa2a694..418483fdb41 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -3,6 +3,7 @@ import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import store from './store';
import RegistrySettingsApp from './components/registry_settings_app.vue';
+import { apolloProvider } from './graphql/index';
Vue.use(GlToast);
Vue.use(Translate);
@@ -13,12 +14,20 @@ export default () => {
return null;
}
store.dispatch('setInitialState', el.dataset);
+ const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset;
return new Vue({
el,
store,
+ apolloProvider,
components: {
RegistrySettingsApp,
},
+ provide: {
+ projectPath,
+ isAdmin,
+ adminSettingsPath,
+ enableHistoricEntries,
+ },
render(createElement) {
return createElement('registry-settings-app', {});
},
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 36d55c7610e..735d72972e6 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = '';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
);
+
+export const KEEP_N_OPTIONS = [
+ { variable: 1, key: 'ONE_TAG', default: false },
+ { variable: 5, key: 'FIVE_TAGS', default: false },
+ { variable: 10, key: 'TEN_TAGS', default: true },
+ { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false },
+ { variable: 50, key: 'FIFTY_TAGS', default: false },
+ { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false },
+];
+
+export const CADENCE_OPTIONS = [
+ { key: 'EVERY_DAY', label: __('Every day'), default: true },
+ { key: 'EVERY_WEEK', label: __('Every week'), default: false },
+ { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
+ { key: 'EVERY_MONTH', label: __('Every month'), default: false },
+ { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
+];
+
+export const OLDER_THAN_OPTIONS = [
+ { key: 'SEVEN_DAYS', variable: 7, default: false },
+ { key: 'FOURTEEN_DAYS', variable: 14, default: false },
+ { key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'NINETY_DAYS', variable: 90, default: true },
+];
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
index a7377773842..f84325cd438 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -1,3 +1,6 @@
+import { n__ } from '~/locale';
+import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+
export const findDefaultOption = options => {
const item = options.find(o => o.default);
return item ? item.key : null;
@@ -17,3 +20,21 @@ export const mapComputedToEvent = (list, root) => {
});
return result;
};
+
+export const optionLabelGenerator = (collection, singularSentence, pluralSentence) =>
+ collection.map(option => ({
+ ...option,
+ label: n__(singularSentence, pluralSentence, option.variable),
+ }));
+
+export const formOptionsGenerator = () => {
+ return {
+ olderThan: optionLabelGenerator(
+ OLDER_THAN_OPTIONS,
+ '%d days until tags are automatically removed',
+ '%d day until tags are automatically removed',
+ ),
+ cadence: CADENCE_OPTIONS,
+ keepN: optionLabelGenerator(KEEP_N_OPTIONS, '%d tag per image name', '%d tags per image name'),
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index 3b05f76d663..a70b8e11a83 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { reduce } from 'lodash';
import { s__ } from '~/locale';
import {
capitalizeFirstCharacter,
@@ -21,10 +22,10 @@ const allowedFields = [
'description',
'endedAt',
'details',
+ 'environment',
];
-const filterAllowedFields = ([fieldName]) => allowedFields.includes(fieldName);
-const arrayToObject = ([fieldName, value]) => ({ fieldName, value });
+const isAllowed = fieldName => allowedFields.includes(fieldName);
export default {
components: {
@@ -62,9 +63,16 @@ export default {
if (!this.alert) {
return [];
}
- return Object.entries(this.alert)
- .filter(filterAllowedFields)
- .map(arrayToObject);
+ return reduce(
+ this.alert,
+ (allowedItems, value, fieldName) => {
+ if (isAllowed(fieldName)) {
+ return [...allowedItems, { fieldName, value }];
+ }
+ return allowedItems;
+ },
+ [],
+ );
},
},
};
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
new file mode 100644
index 00000000000..cbd08cb82ed
--- /dev/null
+++ b/app/helpers/invite_members_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module InviteMembersHelper
+ def invite_members_allowed?(group)
+ Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group)
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 99580a52e96..6b4a71d4e28 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -327,6 +327,8 @@ module Ci
after_transition any => [:success, :failed, :canceled] do |build|
build.run_after_commit do
+ build.run_status_commit_hooks!
+
BuildFinishedWorker.perform_async(id)
end
end
@@ -963,8 +965,24 @@ module Ci
pending_state.try(:delete)
end
+ def run_on_status_commit(&block)
+ status_commit_hooks.push(block)
+ end
+
+ protected
+
+ def run_status_commit_hooks!
+ status_commit_hooks.reverse_each do |hook|
+ instance_eval(&hook)
+ end
+ end
+
private
+ def status_commit_hooks
+ @status_commit_hooks ||= []
+ end
+
def auto_retry
strong_memoize(:auto_retry) do
Gitlab::Ci::Build::AutoRetry.new(self)
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 444742062d9..f18da30e092 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -3,6 +3,7 @@
module Ci
class BuildTraceChunk < ApplicationRecord
extend ::Gitlab::Ci::Model
+ include ::Comparable
include ::FastDestroyAll
include ::Checksummable
include ::Gitlab::ExclusiveLeaseHelpers
@@ -29,6 +30,7 @@ module Ci
}
scope :live, -> { redis }
+ scope :persisted, -> { not_redis.order(:chunk_index) }
class << self
def all_stores
@@ -63,12 +65,24 @@ module Ci
get_store_class(store).delete_keys(value)
end
end
+
+ ##
+ # Sometimes we do not want to read raw data. This method makes it easier
+ # to find attributes that are just metadata excluding raw data.
+ #
+ def metadata_attributes
+ attribute_names - %w[raw_data]
+ end
end
def data
@data ||= get_data.to_s
end
+ def crc32
+ checksum.to_i
+ end
+
def truncate(offset = 0)
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
return if offset == size # Skip the following process as it doesn't affect anything
@@ -130,6 +144,12 @@ module Ci
build.trace_chunks.maximum(:chunk_index).to_i == chunk_index
end
+ def <=>(other)
+ return unless self.build_id == other.build_id
+
+ self.chunk_index <=> other.chunk_index
+ end
+
private
def get_data
@@ -150,7 +170,7 @@ module Ci
self.raw_data = nil
self.data_store = new_store
- self.checksum = crc32(current_data)
+ self.checksum = self.class.crc32(current_data)
##
# We need to so persist data then save a new store identifier before we
diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb
index ea8072099c6..7448afba4c2 100644
--- a/app/models/ci/build_trace_chunks/database.rb
+++ b/app/models/ci/build_trace_chunks/database.rb
@@ -17,6 +17,8 @@ module Ci
def data(model)
model.raw_data
+ rescue ActiveModel::MissingAttributeError
+ model.reset.raw_data
end
def set_data(model, new_data)
diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb
index d6d17bfc604..056abafd0ce 100644
--- a/app/models/concerns/checksummable.rb
+++ b/app/models/concerns/checksummable.rb
@@ -3,11 +3,11 @@
module Checksummable
extend ActiveSupport::Concern
- def crc32(data)
- Zlib.crc32(data)
- end
-
class_methods do
+ def crc32(data)
+ Zlib.crc32(data)
+ end
+
def hexdigest(path)
::Digest::SHA256.file(path).hexdigest
end
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index d223c80fca0..bd245de411c 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -94,13 +94,25 @@ class Iteration < ApplicationRecord
private
+ def parent_group
+ group || project.group
+ end
+
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
- # ensure dates do not overlap with other Iterations in the same group/project
+ # ensure dates do not overlap with other Iterations in the same group/project tree
def dates_do_not_overlap
- return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
+ iterations = if parent_group.present? && resource_parent.is_a?(Project)
+ Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations)
+ elsif parent_group.present?
+ Iteration.where(group: parent_group.self_and_ancestors)
+ else
+ project.iterations
+ end
+
+ return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index b73d2708b14..50d7f1136f2 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -2,6 +2,9 @@
module Ci
class UpdateBuildStateService
+ include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::ExclusiveLeaseHelpers
+
Result = Struct.new(:status, :backoff, keyword_init: true)
ACCEPT_TIMEOUT = 5.minutes.freeze
@@ -17,48 +20,63 @@ module Ci
def execute
overwrite_trace! if has_trace?
- if accept_request?
- accept_build_state!
- else
- check_migration_state
- update_build_state!
+ unless accept_available?
+ return update_build_state!
+ end
+
+ ensure_pending_state!
+
+ in_build_trace_lock do
+ process_build_state!
end
end
private
- def accept_build_state!
- state_created = ensure_pending_state.created_at
+ def overwrite_trace!
+ metrics.increment_trace_operation(operation: :overwrite)
+
+ build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
+ end
- if Time.current - state_created > ACCEPT_TIMEOUT
- metrics.increment_trace_operation(operation: :discarded)
+ def ensure_pending_state!
+ pending_state.created_at
+ end
- return update_build_state!
+ def process_build_state!
+ if live_chunks_pending?
+ if pending_state_outdated?
+ discard_build_trace!
+ update_build_state!
+ else
+ accept_build_state!
+ end
+ else
+ validate_build_trace!
+ update_build_state!
end
+ end
+ def accept_build_state!
build.trace_chunks.live.find_each do |chunk|
chunk.schedule_to_persist!
end
metrics.increment_trace_operation(operation: :accepted)
- ::Gitlab::Ci::Runner::Backoff.new(state_created).then do |backoff|
+ ::Gitlab::Ci::Runner::Backoff.new(pending_state.created_at).then do |backoff|
Result.new(status: 202, backoff: backoff.to_seconds)
end
end
- def overwrite_trace!
- metrics.increment_trace_operation(operation: :overwrite)
-
- build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
- end
-
- def check_migration_state
- return unless accept_available?
-
- if has_chunks? && !live_chunks_pending?
+ def validate_build_trace!
+ if chunks_persisted?
metrics.increment_trace_operation(operation: :finalized)
end
+
+ unless ::Gitlab::Ci::Trace::Checksum.new(build).valid?
+ metrics.increment_trace_operation(operation: :invalid)
+ end
end
def update_build_state!
@@ -80,12 +98,24 @@ module Ci
end
end
+ def discard_build_trace!
+ metrics.increment_trace_operation(operation: :discarded)
+ end
+
def accept_available?
!build_running? && has_checksum? && chunks_migration_enabled?
end
- def accept_request?
- accept_available? && live_chunks_pending?
+ def live_chunks_pending?
+ build.trace_chunks.live.any?
+ end
+
+ def chunks_persisted?
+ build.trace_chunks.any? && !live_chunks_pending?
+ end
+
+ def pending_state_outdated?
+ Time.current - pending_state.created_at > ACCEPT_TIMEOUT
end
def build_state
@@ -100,18 +130,14 @@ module Ci
params.dig(:checksum).present?
end
- def has_chunks?
- build.trace_chunks.any?
- end
-
- def live_chunks_pending?
- build.trace_chunks.live.any?
- end
-
def build_running?
build_state == 'running'
end
+ def pending_state
+ strong_memoize(:pending_state) { ensure_pending_state }
+ end
+
def ensure_pending_state
Ci::BuildPendingState.create_or_find_by!(
build_id: build.id,
@@ -125,6 +151,32 @@ module Ci
build.pending_state
end
+ ##
+ # This method is releasing an exclusive lock on a build trace the moment we
+ # conclude that build status has been written and the build state update
+ # has been committed to the database.
+ #
+ # Because a build state machine schedules a bunch of workers to run after
+ # build status transition to complete, we do not want to keep the lease
+ # until all the workers are scheduled because it opens a possibility of
+ # race conditions happening.
+ #
+ # Instead of keeping the lease until the transition is fully done and
+ # workers are scheduled, we immediately release the lock after the database
+ # commit happens.
+ #
+ def in_build_trace_lock(&block)
+ build.trace.lock do |_, lease| # rubocop:disable CodeReuse/ActiveRecord
+ build.run_on_status_commit { lease.cancel }
+
+ yield
+ end
+ rescue ::Gitlab::Ci::Trace::LockedError
+ metrics.increment_trace_operation(operation: :locked)
+
+ accept_build_state!
+ end
+
def chunks_migration_enabled?
::Gitlab::Ci::Features.accept_trace?(build.project)
end
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
new file mode 100644
index 00000000000..51f41d58029
--- /dev/null
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -0,0 +1,6 @@
+- if invite_members_allowed?(group)
+ .js-invite-members-modal{ data: { group_id: group.id,
+ group_name: group.name,
+ access_levels: GroupMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
new file mode 100644
index 00000000000..1c90eaee992
--- /dev/null
+++ b/app/views/groups/_invite_members_side_nav_link.html.haml
@@ -0,0 +1,3 @@
+- if invite_members_allowed?(group) && body_data_page == 'groups:show'
+ %li
+ .js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } }
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index ec4ab603d22..fa560942c5d 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -23,6 +23,8 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
+= render_if_exists 'groups/invite_members_modal', group: @group
+
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 9e9e6493e5b..5f4b1f8ad45 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -139,6 +139,8 @@
%strong.fly-out-top-item-name
= _('Members')
+ = render_if_exists 'groups/invite_members_side_nav_link', group: @group
+
- if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) do
= link_to edit_group_path(@group) do
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
index c0cef8503e0..b53fac83830 100644
--- a/app/views/projects/registry/settings/_index.haml
+++ b/app/views/projects/registry/settings/_index.haml
@@ -1,4 +1,5 @@
#js-registry-settings{ data: { project_id: @project.id,
+ project_path: @project.full_path,
cadence_options: cadence_options.to_json,
keep_n_options: keep_n_options.to_json,
older_than_options: older_than_options.to_json,
diff --git a/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml b/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml
new file mode 100644
index 00000000000..a2c23138144
--- /dev/null
+++ b/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml
@@ -0,0 +1,5 @@
+---
+title: Fix iteration validation not checking parent groups
+merge_request: 43234
+author:
+type: fixed
diff --git a/changelogs/unreleased/feature-gb-validate-build-traces.yml b/changelogs/unreleased/feature-gb-validate-build-traces.yml
new file mode 100644
index 00000000000..7d7e49d20d4
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-validate-build-traces.yml
@@ -0,0 +1,5 @@
+---
+title: Validate build traces using CRC32 checksums
+merge_request: 42829
+author:
+type: added
diff --git a/config/feature_flags/development/ci_job_heartbeats_runner.yml b/config/feature_flags/development/ci_job_heartbeats_runner.yml
deleted file mode 100644
index dcccd1512ed..00000000000
--- a/config/feature_flags/development/ci_job_heartbeats_runner.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_job_heartbeats_runner
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/invite_members_group_modal.yml b/config/feature_flags/development/invite_members_group_modal.yml
new file mode 100644
index 00000000000..faa905f6557
--- /dev/null
+++ b/config/feature_flags/development/invite_members_group_modal.yml
@@ -0,0 +1,7 @@
+---
+name: invite_members_group_modal
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37906
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247208
+group: group::expansion
+type: development
+default_enabled: false
diff --git a/doc/development/database_review.md b/doc/development/database_review.md
index f56ffdbad21..8a10391419a 100644
--- a/doc/development/database_review.md
+++ b/doc/development/database_review.md
@@ -184,10 +184,6 @@ test its execution using `CREATE INDEX CONCURRENTLY` in the `#database-lab` Slac
- [Check query plans](understanding_explain_plans.md) and suggest improvements
to queries (changing the query, schema or adding indexes and similar)
- General guideline is for queries to come in below 100ms execution time
- - If queries rely on prior migrations that are not present yet on production
- (eg indexes, columns), you can use a [one-off instance from the restore
- pipeline](https://ops.gitlab.net/gitlab-com/gl-infra/gitlab-restore/postgres-gprd)
- in order to establish a proper testing environment. If you don't have access to this project, reach out to #database on Slack to get advice on how to proceed.
- Avoid N+1 problems and minimalize the [query count](merge_request_performance_guidelines.md#query-counts).
### Timing guidelines for migrations
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 93f2c09fd9b..2adfd7532fd 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -38,6 +38,14 @@ The IP address for `mg.gitlab.com` is subject to change at any time.
[See our backup strategy](https://about.gitlab.com/handbook/engineering/infrastructure/production/#backups).
+There are several ways to perform backups of your content on GitLab.com.
+
+Projects can be backed up in their entirety by exporting them either [through the UI](../project/settings/import_export.md) or [API](../../api/project_import_export.md#schedule-an-export), the latter of which can be used to programmatically upload exports to a storage platform such as AWS S3.
+
+With exports, be sure to take note of [what is and is not](../project/settings/import_export.md#exported-contents), included in a project export.
+
+Since GitLab is built on Git, you can back up **just** the repository of a project by [cloning](../../gitlab-basics/start-using-git.md#clone-a-repository) it to another machine. Similarly, if you need to back up just the wiki of a repository it can also be cloned and all files uploaded to that wiki will come with it [if they were uploaded after 2020-08-22](../project/wiki/index.md#creating-a-new-wiki-page).
+
## Alternative SSH port
GitLab.com can be reached via a [different SSH port](https://about.gitlab.com/blog/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/) for `git+ssh`.
diff --git a/doc/user/project/import/github.md b/doc/user/project/import/github.md
index 4cd0c9e02c7..be1641f8b16 100644
--- a/doc/user/project/import/github.md
+++ b/doc/user/project/import/github.md
@@ -35,25 +35,20 @@ The namespace is a user or group in GitLab, such as `gitlab.com/janedoe` or `git
This process does not migrate or import any types of groups or organizations from GitHub to GitLab.
-### If you're using GitLab.com
-
-If you're using GitLab.com, you can alternatively import
-GitHub repositories using a [personal access token](#using-a-github-token),
-but we don't recommend this method because it can't associate all user activity
-(such as issues and pull requests) with matching GitLab users.
-
-### If you're importing from GitLab Enterprise
-
-If you're importing from GitHub Enterprise, you must enable [GitHub integration][gh-import].
-
-### If you're using a self-managed GitLab instance
-
-If you're an administrator of a self-managed GitLab instance, you must enable
-[GitHub integration][gh-import].
-
-If you're an administrator of a self-managed GitLab instance, you can also use the
-[GitHub Rake task](../../../administration/raketasks/github_import.md) to import projects from
-GitHub without the constraints of a Sidekiq worker.
+### Use cases
+
+The steps you take depend on whether you are importing from GitHub.com or GitHub Enterprise, as well as whether you are importing to GitLab.com or self-managed GitLab instance.
+
+- If you're importing to GitLab.com, you can alternatively import GitHub repositories
+ using a [personal access token](#using-a-github-token). We do not recommend
+ this method, as it does not associate all user activity (such as issues and
+ pull requests) with matching GitLab users.
+- If you're importing to a self-managed GitLab instance, you can alternatively use the
+ [GitHub Rake task](../../../administration/raketasks/github_import.md) to import
+ projects without the constraints of a [Sidekiq](../../../development/sidekiq_style_guide.md) worker.
+- If you're importing from GitHub Enterprise to your self-managed GitLab instance, you must first enable
+ [GitHub integration](../../../integration/github.md). However, you cannot import projects from GitHub Enterprise to GitLab.com.
+- If you're importing from GitHub.com to your self-managed GitLab instance, you do not need to set up GitHub integration.
## How it works
diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb
index 34a2fb09875..1c85669a626 100644
--- a/lib/api/helpers/runner.rb
+++ b/lib/api/helpers/runner.rb
@@ -51,9 +51,7 @@ module API
job_forbidden!(job, 'Job is not running') unless job.running?
end
- if Gitlab::Ci::Features.job_heartbeats_runner?(job.project)
- job.runner&.heartbeat(get_runner_ip)
- end
+ job.runner&.heartbeat(get_runner_ip)
job
end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index 6bde1c12ce9..dc628e5673e 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -10,10 +10,6 @@ module Gitlab
::Feature.enabled?(:ci_artifacts_exclude, default_enabled: true)
end
- def self.job_heartbeats_runner?(project)
- ::Feature.enabled?(:ci_job_heartbeats_runner, project, default_enabled: true)
- end
-
def self.instance_variables_ui_enabled?
::Feature.enabled?(:ci_instance_variables_ui, default_enabled: true)
end
diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb
index 348e5472cb4..6fd32b3f1a0 100644
--- a/lib/gitlab/ci/trace.rb
+++ b/lib/gitlab/ci/trace.rb
@@ -16,6 +16,7 @@ module Gitlab
ArchiveError = Class.new(StandardError)
AlreadyArchivedError = Class.new(StandardError)
+ LockedError = Class.new(StandardError)
attr_reader :job
@@ -130,6 +131,12 @@ module Gitlab
end
end
+ def lock(&block)
+ in_write_lock(&block)
+ rescue FailedToObtainLockError
+ raise LockedError, "build trace `#{job.id}` is locked"
+ end
+
private
def read_stream
diff --git a/lib/gitlab/ci/trace/checksum.rb b/lib/gitlab/ci/trace/checksum.rb
new file mode 100644
index 00000000000..ae1df058954
--- /dev/null
+++ b/lib/gitlab/ci/trace/checksum.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Trace
+ ##
+ # Trace::Checksum class is responsible for calculating a CRC32 checksum
+ # of an entire build trace using partial build trace chunks stored in a
+ # database.
+ #
+ # CRC32 checksum can be easily calculated by combining partial checksums
+ # in a right order.
+ #
+ # Then we compare CRC32 checksum provided by a GitLab Runner and expect
+ # it to be the same as the CRC32 checksum derived from partial chunks.
+ #
+ class Checksum
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :build
+
+ def initialize(build)
+ @build = build
+ end
+
+ def valid?
+ return false unless state_crc32 > 0
+
+ state_crc32 == chunks_crc32
+ end
+
+ def state_crc32
+ strong_memoize(:crc32) do
+ build.pending_state&.trace_checksum.then do |checksum|
+ checksum.to_s.split('crc32:').last.to_i
+ end
+ end
+ end
+
+ def chunks_crc32
+ trace_chunks.reduce(0) do |crc32, chunk|
+ Zlib.crc32_combine(crc32, chunk.crc32, chunk_size(chunk))
+ end
+ end
+
+ def last_chunk
+ strong_memoize(:last_chunk) { trace_chunks.max }
+ end
+
+ ##
+ # Trace chunks will be persisted in a database if an object store is
+ # not configured - in that case we do not want to load entire raw data
+ # of all the chunks into memory.
+ #
+ # We ignore `raw_data` attribute instead, and rely on internal build
+ # trace chunk database adapter to handle
+ # `ActiveModel::MissingAttributeError` exception.
+ #
+ # Alternative solution would be separating chunk data from chunk
+ # metadata on the database level too.
+ #
+ def trace_chunks
+ strong_memoize(:trace_chunks) do
+ build.trace_chunks.persisted
+ .select(::Ci::BuildTraceChunk.metadata_attributes)
+ end
+ end
+
+ private
+
+ def chunk_size(chunk)
+ if chunk == last_chunk
+ chunk.size
+ else
+ ::Ci::BuildTraceChunk::CHUNK_SIZE
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/trace/metrics.rb b/lib/gitlab/ci/trace/metrics.rb
index 82a7d5fb83c..cb174ea893a 100644
--- a/lib/gitlab/ci/trace/metrics.rb
+++ b/lib/gitlab/ci/trace/metrics.rb
@@ -7,7 +7,8 @@ module Gitlab
extend Gitlab::Utils::StrongMemoize
OPERATIONS = [:appended, :streamed, :chunked, :mutated, :overwrite,
- :accepted, :finalized, :discarded, :conflict].freeze
+ :accepted, :finalized, :discarded, :conflict, :locked,
+ :invalid].freeze
def increment_trace_operation(operation: :unknown)
unless OPERATIONS.include?(operation)
diff --git a/lib/gitlab/exclusive_lease_helpers.rb b/lib/gitlab/exclusive_lease_helpers.rb
index 10762d83588..da5b0afad38 100644
--- a/lib/gitlab/exclusive_lease_helpers.rb
+++ b/lib/gitlab/exclusive_lease_helpers.rb
@@ -35,7 +35,7 @@ module Gitlab
lease.obtain(1 + retries)
- yield(lease.retried?)
+ yield(lease.retried?, lease)
ensure
lease&.cancel
end
diff --git a/lib/gitlab/metrics/requests_rack_middleware.rb b/lib/gitlab/metrics/requests_rack_middleware.rb
index c8d241d9759..f8ba254c2a7 100644
--- a/lib/gitlab/metrics/requests_rack_middleware.rb
+++ b/lib/gitlab/metrics/requests_rack_middleware.rb
@@ -48,9 +48,10 @@ module Gitlab
method = env['REQUEST_METHOD'].downcase
method = 'INVALID' unless HTTP_METHODS.key?(method)
started = Time.now.to_f
+ health_endpoint = health_endpoint?(env['PATH_INFO'])
begin
- if health_endpoint?(env['PATH_INFO'])
+ if health_endpoint
RequestsRackMiddleware.http_health_requests_total.increment(method: method)
else
RequestsRackMiddleware.http_request_total.increment(method: method)
@@ -59,7 +60,10 @@ module Gitlab
status, headers, body = @app.call(env)
elapsed = Time.now.to_f - started
- RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status.to_s }, elapsed)
+
+ unless health_endpoint
+ RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status.to_s }, elapsed)
+ end
[status, headers, body]
rescue
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f56fadd31e5..fefd0600d58 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,6 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2020-09-22 19:32+0200\n"
+"PO-Revision-Date: 2020-09-22 19:32+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -2234,6 +2236,9 @@ msgstr ""
msgid "AlertManagement|Edit"
msgstr ""
+msgid "AlertManagement|Environment"
+msgstr ""
+
msgid "AlertManagement|Events"
msgstr ""
@@ -13953,6 +13958,42 @@ msgstr ""
msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge."
msgstr ""
+msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
+msgstr ""
+
+msgid "InviteMembersModal|Access expiration date (optional)"
+msgstr ""
+
+msgid "InviteMembersModal|Cancel"
+msgstr ""
+
+msgid "InviteMembersModal|Choose a role permission"
+msgstr ""
+
+msgid "InviteMembersModal|GitLab member or Email address"
+msgstr ""
+
+msgid "InviteMembersModal|Invite"
+msgstr ""
+
+msgid "InviteMembersModal|Invite team members"
+msgstr ""
+
+msgid "InviteMembersModal|Search for members to invite"
+msgstr ""
+
+msgid "InviteMembersModal|User not invited. Feature coming soon!"
+msgstr ""
+
+msgid "InviteMembersModal|Users were succesfully added"
+msgstr ""
+
+msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
+msgstr ""
+
+msgid "InviteMembers|Invite team members"
+msgstr ""
+
msgid "Invited"
msgstr ""
@@ -14624,9 +14665,6 @@ msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
-msgid "Last %{days} days"
-msgstr ""
-
msgid "Last 2 weeks"
msgstr ""
@@ -22928,9 +22966,6 @@ msgstr ""
msgid "Select the environment scope for this feature flag."
msgstr ""
-msgid "Select timeframe"
-msgstr ""
-
msgid "Select timezone"
msgstr ""
@@ -28988,6 +29023,9 @@ msgstr ""
msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly."
msgstr ""
+msgid "YYYY-MM-DD"
+msgstr ""
+
msgid "Yes"
msgstr ""
diff --git a/package.json b/package.json
index 1f5d1b9e9cb..9a1df99f224 100644
--- a/package.json
+++ b/package.json
@@ -42,8 +42,8 @@
"@babel/plugin-syntax-import-meta": "^7.10.1",
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
- "@gitlab/svgs": "1.164.0",
- "@gitlab/ui": "21.4.2",
+ "@gitlab/svgs": "1.168.0",
+ "@gitlab/ui": "21.8.2",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-1",
"@rails/ujs": "^6.0.3-2",
diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb
index 7c348f4b7e4..d996b41b648 100644
--- a/spec/factories/ci/build_trace_chunks.rb
+++ b/spec/factories/ci/build_trace_chunks.rb
@@ -53,5 +53,18 @@ FactoryBot.define do
trait :fog_without_data do
data_store { :fog }
end
+
+ trait :persisted do
+ data_store { :database}
+
+ transient do
+ initial_data { 'test data' }
+ end
+
+ after(:build) do |chunk, evaluator|
+ Ci::BuildTraceChunks::Database.new.set_data(chunk, evaluator.initial_data)
+ chunk.checksum = chunk.class.crc32(evaluator.initial_data)
+ end
+ end
end
end
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index 60f1c404e78..e81f2370d10 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -72,4 +72,12 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
end
+
+ context 'when invite team members is not available' do
+ it 'does not display the js-invite-members-trigger' do
+ visit group_path(group)
+
+ expect(page).not_to have_selector('.js-invite-members-trigger')
+ end
+ end
end
diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js
index 8aa26dbca3b..910bb31b573 100644
--- a/spec/frontend/alert_management/components/alert_details_spec.js
+++ b/spec/frontend/alert_management/components/alert_details_spec.js
@@ -2,8 +2,10 @@ import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertDetails from '~/alert_management/components/alert_details.vue';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import {
@@ -24,31 +26,36 @@ describe('AlertDetails', () => {
const $router = { replace: jest.fn() };
function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) {
- wrapper = mountMethod(AlertDetails, {
- provide: {
- alertId: 'alertId',
- projectPath,
- projectIssuesPath,
- projectId,
- },
- data() {
- return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
- },
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
+ wrapper = extendedWrapper(
+ mountMethod(AlertDetails, {
+ provide: {
+ alertId: 'alertId',
+ projectPath,
+ projectIssuesPath,
+ projectId,
+ },
+ data() {
+ return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ sidebarStatus: {},
},
- sidebarStatus: {},
},
+ $router,
+ $route: { params: {} },
},
- $router,
- $route: { params: {} },
- },
- stubs,
- });
+ stubs: {
+ ...stubs,
+ AlertSummaryRow,
+ },
+ }),
+ );
}
beforeEach(() => {
@@ -62,9 +69,10 @@ describe('AlertDetails', () => {
mock.restore();
});
- const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
- const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]');
- const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]');
+ const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn');
+ const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn');
+ const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
+ const findEnvironmentLink = () => wrapper.findByTestId('environmentUrl');
const findDetailsTable = () => wrapper.find(AlertDetailsTable);
describe('Alert details', () => {
@@ -74,7 +82,7 @@ describe('AlertDetails', () => {
});
it('shows an empty state', () => {
- expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false);
+ expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false);
});
});
@@ -84,28 +92,26 @@ describe('AlertDetails', () => {
});
it('renders a tab with overview information', () => {
- expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('overview').exists()).toBe(true);
});
it('renders a tab with an activity feed', () => {
- expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('activity').exists()).toBe(true);
});
it('renders severity', () => {
- expect(wrapper.find('[data-testid="severity"]').text()).toBe(
+ expect(wrapper.findByTestId('severity').text()).toBe(
ALERTS_SEVERITY_LABELS[mockAlert.severity],
);
});
it('renders a title', () => {
- expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title);
+ expect(wrapper.findByTestId('title').text()).toBe(mockAlert.title);
});
it('renders a start time', () => {
- expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe(
- mockAlert.startedAt,
- );
+ expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true);
+ expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt);
});
});
@@ -114,6 +120,8 @@ describe('AlertDetails', () => {
field | data | isShown
${'eventCount'} | ${1} | ${true}
${'eventCount'} | ${undefined} | ${false}
+ ${'environment'} | ${undefined} | ${false}
+ ${'environment'} | ${'Production'} | ${true}
${'monitoringTool'} | ${'New Relic'} | ${true}
${'monitoringTool'} | ${undefined} | ${false}
${'service'} | ${'Prometheus'} | ${true}
@@ -126,15 +134,29 @@ describe('AlertDetails', () => {
});
it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => {
+ const element = wrapper.findByTestId(field);
if (isShown) {
- expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString());
+ expect(element.text()).toContain(data.toString());
} else {
- expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false);
+ expect(wrapper.findByTestId(field).exists()).toBe(false);
}
});
});
});
+ describe('environment URL fields', () => {
+ it('should show the environment URL when available', () => {
+ const environment = 'Production';
+ const environmentUrl = 'fake/url';
+ mountComponent({
+ data: { alert: { ...mockAlert, environment, environmentUrl } },
+ });
+
+ expect(findEnvironmentLink().text()).toBe(environment);
+ expect(findEnvironmentLink().attributes('href')).toBe(environmentUrl);
+ });
+ });
+
describe('Create incident from alert', () => {
it('should display "View incident" button that links the incident page when incident exists', () => {
const issueIid = '3';
@@ -222,7 +244,7 @@ describe('AlertDetails', () => {
mountComponent({
data: { errored: true, sidebarErrorMessage: '<span data-testid="htmlError" />' },
});
- expect(wrapper.find('[data-testid="htmlError"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('htmlError').exists()).toBe(true);
});
it('does not display an error when dismissed', () => {
@@ -232,7 +254,7 @@ describe('AlertDetails', () => {
});
describe('header', () => {
- const findHeader = () => wrapper.find('[data-testid="alert-header"]');
+ const findHeader = () => wrapper.findByTestId('alert-header');
const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
describe('individual header fields', () => {
diff --git a/spec/frontend/alert_management/components/alert_summary_row_spec.js b/spec/frontend/alert_management/components/alert_summary_row_spec.js
new file mode 100644
index 00000000000..47c715c089a
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_summary_row_spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
+
+const label = 'a label';
+const value = 'a value';
+
+describe('AlertSummaryRow', () => {
+ let wrapper;
+
+ function mountComponent({ mountMethod = shallowMount, props, defaultSlot } = {}) {
+ wrapper = mountMethod(AlertSummaryRow, {
+ propsData: props,
+ scopedSlots: {
+ default: defaultSlot,
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('Alert Summary Row', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ label,
+ },
+ defaultSlot: `<span class="value">${value}</span>`,
+ });
+ });
+
+ it('should display a label and a value', () => {
+ expect(wrapper.text()).toBe(`${label} ${value}`);
+ });
+ });
+});
diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js
index 68326e37ae7..ead898f04d3 100644
--- a/spec/frontend/helpers/vue_test_utils_helper.js
+++ b/spec/frontend/helpers/vue_test_utils_helper.js
@@ -33,3 +33,10 @@ export const waitForMutation = (store, expectedMutationType) =>
}
});
});
+
+export const extendedWrapper = wrapper =>
+ Object.defineProperty(wrapper, 'findByTestId', {
+ value(id) {
+ return this.find(`[data-testid="${id}"]`);
+ },
+ });
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
new file mode 100644
index 00000000000..0be0fbbde2d
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gitlab/ui';
+import Api from '~/api';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+const groupId = '1';
+const groupName = 'testgroup';
+const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
+const defaultAccessLevel = '10';
+const helpLink = 'https://example.com';
+
+const createComponent = () => {
+ return shallowMount(InviteMembersModal, {
+ propsData: {
+ groupId,
+ groupName,
+ accessLevels,
+ defaultAccessLevel,
+ helpLink,
+ },
+ stubs: {
+ GlSprintf,
+ 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
+ },
+ });
+};
+
+describe('InviteMembersModal', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDatepicker = () => wrapper.find(GlDatepicker);
+ const findLink = () => wrapper.find(GlLink);
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
+
+ describe('rendering the modal', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('renders the modal with the correct title', () => {
+ expect(wrapper.attributes('title')).toBe('Invite team members');
+ });
+
+ it('renders the Cancel button text correctly', () => {
+ expect(findCancelButton().text()).toBe('Cancel');
+ });
+
+ it('renders the Invite button text correctly', () => {
+ expect(findInviteButton().text()).toBe('Invite');
+ });
+
+ describe('rendering the access levels dropdown', () => {
+ it('sets the default dropdown text to the default access level name', () => {
+ expect(findDropdown().attributes('text')).toBe('Guest');
+ });
+
+ it('renders dropdown items for each accessLevel', () => {
+ expect(findDropdownItems()).toHaveLength(5);
+ });
+ });
+
+ describe('rendering the help link', () => {
+ it('renders the correct link', () => {
+ expect(findLink().attributes('href')).toBe(helpLink);
+ });
+ });
+
+ describe('rendering the access expiration date field', () => {
+ it('renders the datepicker', () => {
+ expect(findDatepicker()).toExist();
+ });
+ });
+ });
+
+ describe('submitting the invite form', () => {
+ const postData = {
+ user_id: '1',
+ access_level: '10',
+ expires_at: new Date(),
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+
+ jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
+ wrapper.vm.$toast = { show: jest.fn() };
+
+ wrapper.vm.submitForm(postData);
+ });
+
+ it('calls Api inviteGroupMember with the correct params', () => {
+ expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
+ });
+
+ describe('when the invite was sent successfully', () => {
+ const toastMessageSuccessful = 'Users were succesfully added';
+
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
+ toastMessageSuccessful,
+ wrapper.vm.toastOptions,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
new file mode 100644
index 00000000000..450d37a9748
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -0,0 +1,58 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+const displayText = 'Invite team members';
+const icon = 'plus';
+
+const createComponent = (props = {}) => {
+ return shallowMount(InviteMembersTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('InviteMembersTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('displayText', () => {
+ const findLink = () => wrapper.find(GlLink);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findLink().text()).toBe(displayText);
+ });
+ });
+
+ describe('icon', () => {
+ const findIcon = () => wrapper.find(GlIcon);
+
+ it('includes the correct icon when an icon is sent', () => {
+ wrapper = createComponent({ icon });
+
+ expect(findIcon().attributes('name')).toBe(icon);
+ });
+
+ it('does not include an icon when icon is not sent', () => {
+ wrapper = createComponent();
+
+ expect(findIcon().exists()).toBe(false);
+ });
+
+ it('does not include an icon when empty string is sent', () => {
+ wrapper = createComponent({ icon: '' });
+
+ expect(findIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
new file mode 100644
index 00000000000..e5f69a08285
--- /dev/null
+++ b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
@@ -0,0 +1,56 @@
+import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
+
+describe('Registry settings cache update', () => {
+ let client;
+
+ const payload = {
+ data: {
+ updateContainerExpirationPolicy: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ };
+
+ const cacheMock = {
+ project: {
+ containerExpirationPolicy: {
+ enabled: false,
+ },
+ },
+ };
+
+ const queryAndVariables = {
+ query: expirationPolicyQuery,
+ variables: { projectPath: 'foo' },
+ };
+
+ beforeEach(() => {
+ client = {
+ readQuery: jest.fn().mockReturnValue(cacheMock),
+ writeQuery: jest.fn(),
+ };
+ });
+ describe('Registry settings cache update', () => {
+ it('calls readQuery', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables);
+ });
+
+ it('writes the correct result in the cache', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ ...queryAndVariables,
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
new file mode 100644
index 00000000000..1c52249fbf7
--- /dev/null
+++ b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Utils formOptionsGenerator returns an object containing cadence 1`] = `
+Array [
+ Object {
+ "default": true,
+ "key": "EVERY_DAY",
+ "label": "Every day",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_WEEK",
+ "label": "Every week",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_TWO_WEEKS",
+ "label": "Every two weeks",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_MONTH",
+ "label": "Every month",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_THREE_MONTHS",
+ "label": "Every three months",
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "ONE_TAG",
+ "label": "1 tag per image name",
+ "variable": 1,
+ },
+ Object {
+ "default": false,
+ "key": "FIVE_TAGS",
+ "label": "5 tags per image name",
+ "variable": 5,
+ },
+ Object {
+ "default": true,
+ "key": "TEN_TAGS",
+ "label": "10 tags per image name",
+ "variable": 10,
+ },
+ Object {
+ "default": false,
+ "key": "TWENTY_FIVE_TAGS",
+ "label": "25 tags per image name",
+ "variable": 25,
+ },
+ Object {
+ "default": false,
+ "key": "FIFTY_TAGS",
+ "label": "50 tags per image name",
+ "variable": 50,
+ },
+ Object {
+ "default": false,
+ "key": "ONE_HUNDRED_TAGS",
+ "label": "100 tags per image name",
+ "variable": 100,
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "SEVEN_DAYS",
+ "label": "7 day until tags are automatically removed",
+ "variable": 7,
+ },
+ Object {
+ "default": false,
+ "key": "FOURTEEN_DAYS",
+ "label": "14 day until tags are automatically removed",
+ "variable": 14,
+ },
+ Object {
+ "default": false,
+ "key": "THIRTY_DAYS",
+ "label": "30 day until tags are automatically removed",
+ "variable": 30,
+ },
+ Object {
+ "default": true,
+ "key": "NINETY_DAYS",
+ "label": "90 day until tags are automatically removed",
+ "variable": 90,
+ },
+]
+`;
diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/shared/utils_spec.js
new file mode 100644
index 00000000000..a6133fa96d6
--- /dev/null
+++ b/spec/frontend/registry/shared/utils_spec.js
@@ -0,0 +1,27 @@
+import { formOptionsGenerator, optionLabelGenerator } from '~/registry/shared/utils';
+
+describe('Utils', () => {
+ describe('optionLabelGenerator', () => {
+ it('returns an array with a set label', () => {
+ const result = optionLabelGenerator([{ variable: 1 }, { variable: 2 }], '%d day', '%d days');
+ expect(result).toEqual([{ variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }]);
+ });
+ });
+
+ describe('formOptionsGenerator', () => {
+ it('returns an object containing olderThan', () => {
+ expect(formOptionsGenerator().olderThan).toBeDefined();
+ expect(formOptionsGenerator().olderThan).toMatchSnapshot();
+ });
+
+ it('returns an object containing cadence', () => {
+ expect(formOptionsGenerator().cadence).toBeDefined();
+ expect(formOptionsGenerator().cadence).toMatchSnapshot();
+ });
+
+ it('returns an object containing keepN', () => {
+ expect(formOptionsGenerator().keepN).toBeDefined();
+ expect(formOptionsGenerator().keepN).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
index 608a93d11fd..dbdb7705d3c 100644
--- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -1,5 +1,5 @@
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
const mockAlert = {
@@ -61,8 +61,10 @@ describe('AlertDetails', () => {
});
describe('with table data', () => {
+ const environment = 'myEnvironment';
+ const environmentUrl = 'fake/url';
beforeEach(() => {
- mountComponent();
+ mountComponent({ alert: { ...mockAlert, environment, environmentUrl } });
});
it('renders a table', () => {
@@ -80,6 +82,7 @@ describe('AlertDetails', () => {
expect(findTableField(fields, 'Title').exists()).toBe(true);
expect(findTableField(fields, 'Severity').exists()).toBe(true);
expect(findTableField(fields, 'Status').exists()).toBe(true);
+ expect(findTableField(fields, 'Environment').exists()).toBe(true);
});
it('should not show disallowed alert fields', () => {
@@ -89,6 +92,7 @@ describe('AlertDetails', () => {
expect(findTableField(fields, 'Todos').exists()).toBe(false);
expect(findTableField(fields, 'Notes').exists()).toBe(false);
expect(findTableField(fields, 'Assignees').exists()).toBe(false);
+ expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false);
});
});
});
diff --git a/spec/lib/gitlab/ci/trace/checksum_spec.rb b/spec/lib/gitlab/ci/trace/checksum_spec.rb
new file mode 100644
index 00000000000..4bd96aad4e8
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/checksum_spec.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Trace::Checksum do
+ let(:build) { create(:ci_build, :running) }
+
+ subject { described_class.new(build) }
+
+ context 'when build pending state exists' do
+ before do
+ create(:ci_build_pending_state, build: build, trace_checksum: 'crc32:3564598592')
+ end
+
+ context 'when matching persisted trace chunks exist' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 1, data: 'b' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'calculates combined trace chunks CRC32 correctly' do
+ expect(subject.chunks_crc32).to eq 3564598592
+ expect(subject).to be_valid
+ end
+ end
+
+ context 'when trace chunks were persisted in a wrong order' do
+ before do
+ create_chunk(index: 0, data: 'b' * 128.kilobytes)
+ create_chunk(index: 1, data: 'a' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when one of the trace chunks is missing' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when checksums of persisted trace chunks do not match' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 1, data: 'X' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when persisted trace chunks are missing' do
+ it 'makes trace checksum invalid' do
+ expect(subject.state_crc32).to eq 3564598592
+ expect(subject).not_to be_valid
+ end
+ end
+ end
+
+ context 'when build pending state is missing' do
+ describe '#state_crc32' do
+ it 'returns zero' do
+ expect(subject.state_crc32).to be_zero
+ end
+ end
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe '#trace_chunks' do
+ before do
+ create_chunk(index: 0, data: 'abcdefg')
+ end
+
+ it 'does not load raw_data from a database store' do
+ subject.trace_chunks.first.then do |chunk|
+ expect(chunk).to be_database
+ expect { chunk.raw_data }
+ .to raise_error ActiveModel::MissingAttributeError
+ end
+ end
+ end
+
+ describe '#last_chunk' do
+ context 'when there are no chunks' do
+ it 'returns nil' do
+ expect(subject.last_chunk).to be_nil
+ end
+ end
+
+ context 'when there are multiple chunks' do
+ before do
+ create_chunk(index: 1, data: '1234')
+ create_chunk(index: 0, data: 'abcd')
+ end
+
+ it 'returns chunk with the highest index' do
+ expect(subject.last_chunk.chunk_index).to eq 1
+ end
+ end
+ end
+
+ def create_chunk(index:, data:)
+ create(:ci_build_trace_chunk, :persisted, build: build,
+ chunk_index: index,
+ initial_data: data)
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 171877dbaee..f037e803fb4 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -111,4 +111,13 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state do
end
end
end
+
+ describe '#lock' do
+ it 'acquires an exclusive lease on the trace' do
+ trace.lock do
+ expect { trace.lock }
+ .to raise_error described_class::LockedError
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
index 01e2fe8ce17..40669f06371 100644
--- a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
@@ -25,13 +25,17 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
let!(:lease) { stub_exclusive_lease(unique_key, 'uuid') }
it 'calls the given block' do
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
end
it 'calls the given block continuously' do
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
end
it 'cancels the exclusive lease after the block' do
@@ -74,7 +78,8 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
expect(lease).to receive(:try_obtain).exactly(3).times { nil }
expect(lease).to receive(:try_obtain).once { unique_key }
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(true)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(true, an_instance_of(described_class::SleepingLock))
end
end
end
diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
index 69b779d36eb..0c77dc540f3 100644
--- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
@@ -38,69 +38,49 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
end
context 'request is a health check endpoint' do
- it 'increments health endpoint counter' do
- env['PATH_INFO'] = '/-/liveness'
+ ['/-/liveness', '/-/liveness/', '/-/%6D%65%74%72%69%63%73'].each do |path|
+ context "when path is #{path}" do
+ before do
+ env['PATH_INFO'] = path
+ end
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ it 'increments health endpoint counter rather than overall counter' do
+ expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ expect(described_class).not_to receive(:http_request_total)
- subject.call(env)
- end
-
- context 'with trailing slash' do
- before do
- env['PATH_INFO'] = '/-/liveness/'
- end
+ subject.call(env)
+ end
- it 'increments health endpoint counter' do
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ it 'does not record the request duration' do
+ expect(described_class).not_to receive(:http_request_duration_seconds)
- subject.call(env)
- end
- end
-
- context 'with percent encoded values' do
- before do
- env['PATH_INFO'] = '/-/%6D%65%74%72%69%63%73' # /-/metrics
- end
-
- it 'increments health endpoint counter' do
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
-
- subject.call(env)
+ subject.call(env)
+ end
end
end
end
context 'request is not a health check endpoint' do
- it 'does not increment health endpoint counter' do
- env['PATH_INFO'] = '/-/ordinary-requests'
-
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
- end
-
- context 'path info is a root path' do
- before do
- env['PATH_INFO'] = '/-/'
- end
-
- it 'does not increment health endpoint counter' do
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
- end
- end
-
- context 'path info is a subpath' do
- before do
- env['PATH_INFO'] = '/-/health/subpath'
- end
-
- it 'does not increment health endpoint counter' do
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
+ ['/-/ordinary-requests', '/-/', '/-/health/subpath'].each do |path|
+ context "when path is #{path}" do
+ before do
+ env['PATH_INFO'] = path
+ end
+
+ it 'increments overall counter rather than health endpoint counter' do
+ expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get')
+ expect(described_class).not_to receive(:http_health_requests_total)
+
+ subject.call(env)
+ end
+
+ it 'records the request duration' do
+ expect(described_class)
+ .to receive_message_chain(:http_request_duration_seconds, :observe)
+ .with({ method: 'get', status: '200' }, a_positive_execution_time)
+
+ subject.call(env)
+ end
end
end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 1e551d9ee33..cb29cbcbb72 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -4652,4 +4652,24 @@ RSpec.describe Ci::Build do
it { is_expected.to be_nil }
end
end
+
+ describe '#run_on_status_commit' do
+ it 'runs provided hook after status commit' do
+ action = spy('action')
+
+ build.run_on_status_commit { action.perform! }
+ build.success!
+
+ expect(action).to have_received(:perform!).once
+ end
+
+ it 'does not run hooks when status has not changed' do
+ action = spy('action')
+
+ build.run_on_status_commit { action.perform! }
+ build.save!
+
+ expect(action).not_to have_received(:perform!)
+ end
+ end
end
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
index fefe5e3bfca..57e58fe494f 100644
--- a/spec/models/ci/build_trace_chunk_spec.rb
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -779,4 +779,62 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
it_behaves_like 'deletes all build_trace_chunk and data in redis'
end
end
+
+ describe 'comparable build trace chunks' do
+ describe '#<=>' do
+ context 'when chunks are associated with different builds' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, chunk_index: 1) }
+
+ it 'returns nil' do
+ expect(first <=> second).to be_nil
+ end
+ end
+
+ context 'when there are two chunks with different indexes' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, build: build, chunk_index: 0) }
+
+ it 'indicates the the first one is greater than then second' do
+ expect(first <=> second).to eq 1
+ end
+ end
+
+ context 'when there are two chunks with the same index within the same build' do
+ let(:chunk) { create(:ci_build_trace_chunk) }
+
+ it 'indicates the these are equal' do
+ expect(chunk <=> chunk).to be_zero # rubocop:disable Lint/UselessComparison
+ end
+ end
+ end
+
+ describe '#==' do
+ context 'when chunks have the same index' do
+ let(:chunk) { create(:ci_build_trace_chunk) }
+
+ it 'indicates that the chunks are equal' do
+ expect(chunk).to eq chunk
+ end
+ end
+
+ context 'when chunks have different indexes' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, build: build, chunk_index: 0) }
+
+ it 'indicates that the chunks are not equal' do
+ expect(first).not_to eq second
+ end
+ end
+
+ context 'when chunks are associated with different builds' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, chunk_index: 1) }
+
+ it 'indicates that the chunks are not equal' do
+ expect(first).not_to eq second
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/checksummable_spec.rb b/spec/models/concerns/checksummable_spec.rb
index b469b2e5c18..3a0387333e8 100644
--- a/spec/models/concerns/checksummable_spec.rb
+++ b/spec/models/concerns/checksummable_spec.rb
@@ -3,17 +3,21 @@
require 'spec_helper'
RSpec.describe Checksummable do
- describe ".hexdigest" do
- let(:fake_class) do
- Class.new do
- include Checksummable
- end
+ subject do
+ Class.new { include Checksummable }
+ end
+
+ describe ".crc32" do
+ it 'returns the CRC32 of data' do
+ expect(subject.crc32('abcd')).to eq 3984772369
end
+ end
+ describe ".hexdigest" do
it 'returns the SHA256 sum of the file' do
expected = Digest::SHA256.file(__FILE__).hexdigest
- expect(fake_class.hexdigest(__FILE__)).to eq(expected)
+ expect(subject.hexdigest(__FILE__)).to eq(expected)
end
end
end
diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb
index 19a1625aad3..e7ec5de0ef1 100644
--- a/spec/models/iteration_spec.rb
+++ b/spec/models/iteration_spec.rb
@@ -119,7 +119,7 @@ RSpec.describe Iteration do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
- shared_examples_for 'overlapping dates' do
+ shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
context 'when start_date is in range' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
@@ -129,9 +129,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
@@ -144,9 +146,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
@@ -156,9 +160,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
end
@@ -177,6 +183,14 @@ RSpec.describe Iteration do
expect { subject.save! }.not_to raise_exception
end
end
+
+ context 'sub-group' do
+ let(:subgroup) { create(:group, parent: group) }
+
+ subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
+
+ it_behaves_like 'overlapping dates', skip_constraint_test: true
+ end
end
context 'project' do
@@ -210,6 +224,17 @@ RSpec.describe Iteration do
end
end
end
+
+ context 'project in a group' do
+ let_it_be(:project) { create(:project, group: create(:group)) }
+ let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
+
+ subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
+
+ it_behaves_like 'overlapping dates' do
+ let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
+ end
+ end
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 7fe3717e251..fb2c3be21bf 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -229,13 +229,17 @@ RSpec.describe Member do
end
describe '.not_expired' do
- let_it_be(:expiring_yesterday) { create(:group_member, expires_at: 1.day.ago) }
- let_it_be(:expiring_today) { create(:group_member, expires_at: Date.today) }
- let_it_be(:expiring_tomorrow) { create(:group_member, expires_at: 1.day.from_now) }
+ let_it_be(:expiring_yesterday) { create(:group_member, expires_at: 1.day.from_now) }
+ let_it_be(:expiring_today) { create(:group_member, expires_at: 2.days.from_now) }
+ let_it_be(:expiring_tomorrow) { create(:group_member, expires_at: 3.days.from_now) }
let_it_be(:not_expiring) { create(:group_member) }
subject { described_class.not_expired }
+ around do |example|
+ travel_to(2.days.from_now) { example.run }
+ end
+
it { is_expected.not_to include(expiring_yesterday, expiring_today) }
it { is_expected.to include(expiring_tomorrow, not_expiring) }
end
diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb
index 95602a4de0e..30690ce2fa3 100644
--- a/spec/models/snippet_repository_spec.rb
+++ b/spec/models/snippet_repository_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe SnippetRepository do
it 'returns nil when files argument is empty' do
expect(snippet.repository).not_to receive(:multi_action)
- operation = snippet_repository.multi_files_action(user, [], commit_opts)
+ operation = snippet_repository.multi_files_action(user, [], **commit_opts)
expect(operation).to be_nil
end
@@ -43,7 +43,7 @@ RSpec.describe SnippetRepository do
it 'returns nil when files argument is nil' do
expect(snippet.repository).not_to receive(:multi_action)
- operation = snippet_repository.multi_files_action(user, nil, commit_opts)
+ operation = snippet_repository.multi_files_action(user, nil, **commit_opts)
expect(operation).to be_nil
end
@@ -60,7 +60,7 @@ RSpec.describe SnippetRepository do
end
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
aggregate_failures do
@@ -77,13 +77,13 @@ RSpec.describe SnippetRepository do
it 'tries to obtain an exclusive lease' do
expect(Gitlab::ExclusiveLease).to receive(:new).with("multi_files_action:#{snippet.id}", anything).and_call_original
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
it 'cancels the lease when the method has finished' do
expect(Gitlab::ExclusiveLease).to receive(:cancel).with("multi_files_action:#{snippet.id}", anything).and_call_original
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
it 'raises an error if the lease cannot be obtained' do
@@ -92,7 +92,7 @@ RSpec.describe SnippetRepository do
end
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.to raise_error(described_class::CommitError)
end
@@ -114,7 +114,7 @@ RSpec.describe SnippetRepository do
it 'infers the commit action based on the parameters if not present' do
expect(repo).to receive(:multi_action).with(user, hash_including(actions: result))
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
context 'when commit actions are present' do
@@ -128,7 +128,7 @@ RSpec.describe SnippetRepository do
user,
hash_including(actions: array_including(hash_including(action: expected_action)))))
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
end
@@ -149,7 +149,7 @@ RSpec.describe SnippetRepository do
specify do
existing_content = blob_at(snippet, previous_path).data
- snippet_repository.multi_files_action(user, [move_action], commit_opts)
+ snippet_repository.multi_files_action(user, [move_action], **commit_opts)
blob = blob_at(snippet, new_path)
expect(blob).not_to be_nil
@@ -177,7 +177,7 @@ RSpec.describe SnippetRepository do
specify do
last_commit_id = snippet.repository.head_commit.id
- snippet_repository.multi_files_action(user, [update_action], commit_opts)
+ snippet_repository.multi_files_action(user, [update_action], **commit_opts)
expect(snippet.repository.head_commit.id).to eq last_commit_id
end
@@ -214,13 +214,13 @@ RSpec.describe SnippetRepository do
before do
expect(blob_at(snippet, default_name)).to be_nil
- snippet_repository.multi_files_action(user, [new_file], commit_opts)
+ snippet_repository.multi_files_action(user, [new_file], **commit_opts)
expect(blob_at(snippet, default_name)).to be
end
it 'reuses the existing file name' do
- snippet_repository.multi_files_action(user, [existing_file], commit_opts)
+ snippet_repository.multi_files_action(user, [existing_file], **commit_opts)
blob = blob_at(snippet, default_name)
expect(blob.data).to eq existing_file[:content]
@@ -234,7 +234,7 @@ RSpec.describe SnippetRepository do
it 'assigns a new name to the file' do
expect(blob_at(snippet, default_name)).to be_nil
- snippet_repository.multi_files_action(user, [new_file], commit_opts)
+ snippet_repository.multi_files_action(user, [new_file], **commit_opts)
blob = blob_at(snippet, default_name)
expect(blob.data).to eq new_file[:content]
@@ -246,7 +246,7 @@ RSpec.describe SnippetRepository do
before do
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
end
@@ -259,10 +259,10 @@ RSpec.describe SnippetRepository do
before do
# Pre-populate repository with 9 unnamed snippets.
- snippet_repository.multi_files_action(user, pre_populate_data, commit_opts)
+ snippet_repository.multi_files_action(user, pre_populate_data, **commit_opts)
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
end
@@ -274,7 +274,7 @@ RSpec.describe SnippetRepository do
it 'raises a path specific error' do
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.to raise_error(error)
end
end
diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb
index 4feb65aa3cf..92d38621105 100644
--- a/spec/requests/api/ci/runner/jobs_put_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -120,19 +120,15 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when runner retries request after receiving 202' do
it 'responds with 202 and then with 200', :sidekiq_inline do
- perform_enqueued_jobs do
- update_job(state: 'success', checksum: 'crc32:12345678')
- end
+ update_job(state: 'success', checksum: 'crc32:12345678')
- expect(job.reload.pending_state).to be_present
expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.pending_state).to be_present
- perform_enqueued_jobs do
- update_job(state: 'success', checksum: 'crc32:12345678')
- end
+ update_job(state: 'success', checksum: 'crc32:12345678')
- expect(job.reload.pending_state).not_to be_present
expect(response).to have_gitlab_http_status(:ok)
+ expect(job.reload.pending_state).not_to be_present
end
end
@@ -145,7 +141,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
update_job(state: 'success', checksum: 'crc:12345678')
expect(job.reload).to be_success
- expect(job.pending_state).not_to be_present
+ expect(job.pending_state).to be_present
expect(response).to have_gitlab_http_status(:ok)
expect(response.header).not_to have_key('X-GitLab-Trace-Update-Interval')
end
diff --git a/spec/services/ci/update_build_state_service_spec.rb b/spec/services/ci/update_build_state_service_spec.rb
index 333231cbd83..751f9f77908 100644
--- a/spec/services/ci/update_build_state_service_spec.rb
+++ b/spec/services/ci/update_build_state_service_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe Ci::UpdateBuildStateService do
context 'when build trace has been migrated' do
before do
- create(:ci_build_trace_chunk, :database_with_data, build: build)
+ create(:ci_build_trace_chunk, :persisted, build: build, initial_data: 'abcd')
end
it 'updates a build state' do
@@ -113,6 +113,48 @@ RSpec.describe Ci::UpdateBuildStateService do
.to have_received(:increment_trace_operation)
.with(operation: :finalized)
end
+
+ context 'when trace checksum is not valid' do
+ it 'increments invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+ end
+
+ context 'when trace checksum is valid' do
+ let(:params) { { checksum: 'crc32:3984772369', state: 'success' } }
+
+ it 'does not increment invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+ end
+
+ context 'when failed to acquire a build trace lock' do
+ it 'accepts a state update request' do
+ build.trace.lock do
+ result = subject.execute
+
+ expect(result.status).to eq 202
+ end
+ end
+
+ it 'increment locked trace metric' do
+ build.trace.lock do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :locked)
+ end
+ end
+ end
end
context 'when build trace has not been migrated yet' do
@@ -146,14 +188,6 @@ RSpec.describe Ci::UpdateBuildStateService do
subject.execute
end
- it 'increments trace accepted operation metric' do
- execute_with_stubbed_metrics!
-
- expect(metrics)
- .to have_received(:increment_trace_operation)
- .with(operation: :accepted)
- end
-
it 'creates a pending state record' do
subject.execute
@@ -165,6 +199,22 @@ RSpec.describe Ci::UpdateBuildStateService do
end
end
+ it 'increments trace accepted operation metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :accepted)
+ end
+
+ it 'does not increment invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+
context 'when build pending state is outdated' do
before do
build.create_pending_state(
diff --git a/spec/support/shared_examples/features/navbar_shared_examples.rb b/spec/support/shared_examples/features/navbar_shared_examples.rb
index 91a4048fa7c..c768e95c45a 100644
--- a/spec/support/shared_examples/features/navbar_shared_examples.rb
+++ b/spec/support/shared_examples/features/navbar_shared_examples.rb
@@ -3,12 +3,14 @@
RSpec.shared_examples 'verified navigation bar' do
let(:expected_structure) do
structure.compact!
- structure.each { |s| s[:nav_sub_items].compact! }
+ structure.each { |s| s[:nav_sub_items]&.compact! }
structure
end
it 'renders correctly' do
current_structure = page.all('.sidebar-top-level-items > li', class: ['!hidden']).map do |item|
+ next if item.find_all('a').empty?
+
nav_item = item.find_all('a').first.text.gsub(/\s+\d+$/, '') # remove counts at the end
nav_sub_items = item.all('.sidebar-sub-level-items > li', class: ['!fly-out-top-item']).map do |list_item|
@@ -16,7 +18,7 @@ RSpec.shared_examples 'verified navigation bar' do
end
{ nav_item: nav_item, nav_sub_items: nav_sub_items }
- end
+ end.compact
expect(current_structure).to eq(expected_structure)
end
diff --git a/yarn.lock b/yarn.lock
index a01fb3870af..09ec159cf3e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
-"@gitlab/svgs@1.164.0":
- version "1.164.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.164.0.tgz#6cefad871c45f945ef92b99015d0f510b1d2de4a"
- integrity sha512-a9e/cYUc1QQk7azjH4x/m6/p3icavwGEi5F9ipNlDqiJtUor5tqojxvMxPOhuVbN/mTwnC6lGsSZg4tqTsdJAQ==
-
-"@gitlab/ui@21.4.2":
- version "21.4.2"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.4.2.tgz#c3d36167ab4df49ce978e20bdd3790e716f5a2d1"
- integrity sha512-p8ujeGvCG06Opn0eQlrwZyi9v9RK3T2V4TUcljTAUYDdm0p23qJjjIlFjfGHlQsNg0wRgnkbKFXfkZ/Oy8GyiQ==
+"@gitlab/svgs@1.168.0":
+ version "1.168.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.168.0.tgz#ad13671756601ee46f690068fd1ee4145f384e38"
+ integrity sha512-PO2QrnFKlgKn7Xm4Iuepr1HZfkDiwkbzRjyGGd5ugDVHXMiLB3mHStp1QW1NAowlcP8kQ2f2wkLsV+EDkUPiDg==
+
+"@gitlab/ui@21.8.2":
+ version "21.8.2"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.8.2.tgz#28efc2c3180ace1441524b3358fb56d6c80432f9"
+ integrity sha512-H9eY9DRBB/m0RGQABtDkXj70JIDFo/iA2sVOkmaMlVBotML8Mjzs1XNZXNFFqjo9MpH/xf7GlKCHC/aFnOFXDw==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"