diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-28 15:09:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-28 15:09:44 +0000 |
commit | 34b3acb5a3a9b21490e45b81b81dca600b66521c (patch) | |
tree | 81deb74283f931cdbf65b8878b41085b0213a9e6 | |
parent | effda22b3e6367cefd12666463b8409bf7e24cef (diff) | |
download | gitlab-ce-34b3acb5a3a9b21490e45b81b81dca600b66521c.tar.gz |
Add latest changes from gitlab-org/gitlab@master
73 files changed, 1770 insertions, 314 deletions
@@ -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" |