diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 15:09:13 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 15:09:13 +0300 |
commit | 255831389a5080bb61242b3b50426918c4e1a5aa (patch) | |
tree | f5b888bc321828d7f607ed9cf25a98b2a78b1986 | |
parent | 375c6d54dd85bfdf4be302c9cdac088a58b64c59 (diff) |
Add latest changes from gitlab-org/gitlab@master
41 files changed, 996 insertions, 262 deletions
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 00b4e6c96a9..65b6bc6b98e 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -32,6 +32,8 @@ export default { SidebarTodoWidget, SidebarSeverity, MountingPortal, + SidebarHealthStatusWidget: () => + import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'), SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), IterationSidebarDropdownWidget: () => @@ -51,6 +53,9 @@ export default { weightFeatureAvailable: { default: false, }, + healthStatusFeatureAvailable: { + default: false, + }, allowLabelEdit: { default: false, }, @@ -115,6 +120,7 @@ export default { 'setActiveItemConfidential', 'setActiveBoardItemLabels', 'setActiveItemWeight', + 'setActiveItemHealthStatus', ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); @@ -236,6 +242,13 @@ export default { :issuable-type="issuableType" @weightUpdated="setActiveItemWeight($event)" /> + <sidebar-health-status-widget + v-if="healthStatusFeatureAvailable" + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @statusUpdated="setActiveItemHealthStatus($event)" + /> <sidebar-confidentiality-widget :iid="activeBoardItem.iid" :full-path="fullPath" diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e5437690fd4..07b127d86e2 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -928,4 +928,5 @@ export default { // EE action needs CE empty equivalent setActiveItemWeight: () => {}, + setActiveItemHealthStatus: () => {}, }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 0ddf5def8ee..b091fbadcd7 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -161,6 +161,18 @@ function toggleLoader(state) { $('.mr-loading-status .loading').toggleClass('hide', !state); } +function getActionFromHref(href) { + let action = new URL(href).pathname.match(/\/(commits|diffs|pipelines).*$/); + + if (action) { + action = action[0].replace(/(^\/|\.html)/g, ''); + } else { + action = 'show'; + } + + return action; +} + export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); @@ -206,12 +218,11 @@ export default class MergeRequestTabs { bindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); - window.addEventListener('popstate', (event) => { - if (event.state && event.state.action) { - this.tabShown(event.state.action, event.target.location); - this.currentAction = event.state.action; - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); - } + window.addEventListener('popstate', () => { + const action = getActionFromHref(location.href); + + this.tabShown(action, location.href); + this.eventHub.$emit('MergeRequestTabChange', action); }); } @@ -252,17 +263,18 @@ export default class MergeRequestTabs { } else if (action) { const href = e.currentTarget.getAttribute('href'); this.tabShown(action, href); - - if (this.setUrl) { - this.setCurrentAction(action); - } } } } tabShown(action, href, shouldScroll = true) { + toggleLoader(false); + if (action !== this.currentTab && this.mergeRequestTabs) { this.currentTab = action; + if (this.setUrl) { + this.setCurrentAction(action); + } if (this.mergeRequestTabPanesAll) { this.mergeRequestTabPanesAll.forEach((el) => { @@ -398,7 +410,7 @@ export default class MergeRequestTabs { // Ensure parameters and hash come along for the ride newState += location.search + location.hash; - if (window.history.state && window.history.state.url && window.location.pathname !== newState) { + if (window.location.pathname !== newState) { window.history.pushState( { url: newState, @@ -477,8 +489,6 @@ export default class MergeRequestTabs { return; } - toggleLoader(true); - loadDiffs({ // We extract pathname for the current Changes tab anchor href // some pages like MergeRequestsController#new has query parameters on that anchor @@ -496,9 +506,6 @@ export default class MergeRequestTabs { createAlert({ message: __('An error occurred while fetching this tab.'), }); - }) - .finally(() => { - toggleLoader(false); }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index c54672cd0f8..a15bf6fadd8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -556,6 +556,16 @@ export default { </ul> </div> <div class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5"> + <template v-if="sourceHasDivergedFromTarget"> + <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + $options.i18n.divergedCommits(mr.divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + · + </template> <added-commit-message :is-squash-enabled="squashBeforeMerge" :is-fast-forward-enabled="!shouldShowMergeEdit" diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js index 03bd64e2a57..03bd64e2a57 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 7e9fa24e3f5..8bc78170872 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -27,6 +27,7 @@ import { WORK_ITEM_VIEWED_STORAGE_KEY, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ITERATION, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, } from '../constants'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; @@ -37,6 +38,7 @@ import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import { getWorkItemQuery } from '../utils'; +import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -73,6 +75,7 @@ export default { WorkItemTypeIcon, WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), WorkItemMilestone, + WorkItemTree, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -323,6 +326,7 @@ export default { }, }, WORK_ITEM_VIEWED_STORAGE_KEY, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; </script> @@ -512,6 +516,10 @@ export default { class="gl-pt-5" @error="updateError = $event" /> + <work-item-tree + v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :work-item-type="workItemType" + /> <gl-empty-state v-if="error" :title="$options.i18n.fetchErrorTitle" diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue new file mode 100644 index 00000000000..a91133ce1ac --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -0,0 +1,66 @@ +<script> +import { GlDropdown, GlDropdownDivider, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +const objectiveActionItems = [ + { + title: s__('OKR|New objective'), + eventName: 'showCreateObjectiveForm', + }, + { + title: s__('OKR|Existing objective'), + eventName: 'showAddObjectiveForm', + }, +]; + +const keyResultActionItems = [ + { + title: s__('OKR|New key result'), + eventName: 'showCreateKeyResultForm', + }, + { + title: s__('OKR|Existing key result'), + eventName: 'showAddKeyResultForm', + }, +]; + +export default { + keyResultActionItems, + objectiveActionItems, + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + }, + methods: { + change({ eventName }) { + this.$emit(eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown :text="__('Add')" size="small" right> + <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.objectiveActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.keyResultActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue new file mode 100644 index 00000000000..49430cc4064 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -0,0 +1,92 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { WORK_ITEMS_TREE_TEXT_MAP } from '../../constants'; +import OkrActionsSplitButton from './okr_actions_split_button.vue'; + +export default { + WORK_ITEMS_TREE_TEXT_MAP, + components: { + GlButton, + OkrActionsSplitButton, + }, + props: { + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + isShownAddForm: false, + isOpen: true, + error: null, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); + }, + }, + methods: { + toggle() { + this.isOpen = !this.isOpen; + }, + showAddForm() { + this.isOpen = true; + this.isShownAddForm = true; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; + }, + }, +}; +</script> + +<template> + <div + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + data-testid="work-item-tree" + > + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </h5> + </div> + <okr-actions-split-button /> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-tree" + @click="toggle" + /> + </div> + </div> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="tree-body" + > + <div v-if="!isShownAddForm && !error" data-testid="tree-empty"> + <p class="gl-mb-3"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} + </p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 8b47c24de7d..7fcbb8c07fe 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -28,6 +28,10 @@ export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; +export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; + +export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; + export const i18n = { fetchErrorTitle: s__('WorkItem|Work item not found'), fetchError: s__( @@ -100,6 +104,17 @@ export const WORK_ITEMS_TYPE_MAP = { icon: `issue-type-requirements`, name: s__('WorkItem|Requirements'), }, + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Objective'), + }, +}; + +export const WORK_ITEMS_TREE_TEXT_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: { + title: s__('WorkItem|Child objectives and key results'), + empty: s__('WorkItem|No objectives or key results are currently assigned.'), + }, }; export const FORM_TYPES = { diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 2e662faea6a..0570bc2f395 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -19,7 +19,7 @@ class UserDetail < ApplicationRecord validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true - validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true + validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true, if: :website_url_changed? before_validation :sanitize_attrs before_save :prevent_nil_bio diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml index efb14cdea36..f0d6ecaf26f 100644 --- a/config/gitlab_loose_foreign_keys.yml +++ b/config/gitlab_loose_foreign_keys.yml @@ -166,6 +166,10 @@ clusters_applications_runners: - table: ci_runners column: runner_id on_delete: async_nullify +dast_pre_scan_verifications: + - table: ci_pipelines + column: ci_pipeline_id + on_delete: async_delete dast_profiles_pipelines: - table: ci_pipelines column: ci_pipeline_id diff --git a/db/docs/dast_pre_scan_verifications.yml b/db/docs/dast_pre_scan_verifications.yml new file mode 100644 index 00000000000..0c57a710894 --- /dev/null +++ b/db/docs/dast_pre_scan_verifications.yml @@ -0,0 +1,9 @@ +--- +table_name: dast_pre_scan_verifications +classes: +- Dast::PreScanVerifications +feature_categories: +- dynamic_application_security_testing +description: Verification status for DAST Profiles +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/103063 +milestone: '15.6' diff --git a/db/migrate/20221103205317_create_dast_pre_scan_verification.rb b/db/migrate/20221103205317_create_dast_pre_scan_verification.rb new file mode 100644 index 00000000000..85375be53b5 --- /dev/null +++ b/db/migrate/20221103205317_create_dast_pre_scan_verification.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateDastPreScanVerification < Gitlab::Database::Migration[2.0] + def up + create_table :dast_pre_scan_verifications do |t| + t.references :dast_profile, null: false, foreign_key: { on_delete: :cascade }, + index: { name: 'index_dast_pre_scan_verifications_on_dast_profile_id' } + + t.bigint :ci_pipeline_id, null: false + + t.timestamps_with_timezone + + t.integer :status, default: 0, limit: 2, null: false + + t.index :ci_pipeline_id, unique: true, name: :index_dast_pre_scan_verifications_on_ci_pipeline_id + end + end + + def down + drop_table :dast_pre_scan_verifications + end +end diff --git a/db/post_migrate/20221115120602_add_index_for_issues_health_status_ordering.rb b/db/post_migrate/20221115120602_add_index_for_issues_health_status_ordering.rb new file mode 100644 index 00000000000..d7d861387fd --- /dev/null +++ b/db/post_migrate/20221115120602_add_index_for_issues_health_status_ordering.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddIndexForIssuesHealthStatusOrdering < Gitlab::Database::Migration[2.0] + INDEX_NAME_DESC = 'index_on_issues_health_status_desc_order' + INDEX_NAME_ASC = 'index_on_issues_health_status_asc_order' + + def up + prepare_async_index :issues, + [:project_id, :health_status, :id, :state_id, :issue_type], + order: { health_status: 'DESC NULLS LAST', id: :desc }, + name: INDEX_NAME_DESC + + prepare_async_index :issues, + [:project_id, :health_status, :id, :state_id, :issue_type], + order: { health_status: 'ASC NULLS LAST', id: :desc }, + name: INDEX_NAME_ASC + end + + def down + unprepare_async_index :issues, INDEX_NAME_DESC + unprepare_async_index :issues, INDEX_NAME_ASC + end +end diff --git a/db/schema_migrations/20221103205317 b/db/schema_migrations/20221103205317 new file mode 100644 index 00000000000..f205ff2db21 --- /dev/null +++ b/db/schema_migrations/20221103205317 @@ -0,0 +1 @@ +d1d3c4281b79318902e3e26d9104971a4537fd6380ce5f53282073330ab173e6
\ No newline at end of file diff --git a/db/schema_migrations/20221115120602 b/db/schema_migrations/20221115120602 new file mode 100644 index 00000000000..e7d0bfac37b --- /dev/null +++ b/db/schema_migrations/20221115120602 @@ -0,0 +1 @@ +793a1e1c80385cf7fe8f2d27af9acc64f46298790c6dc353f5355047500eebb9
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 92dbe722bf9..e88ff1be365 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14369,6 +14369,24 @@ CREATE SEQUENCE customer_relations_organizations_id_seq ALTER SEQUENCE customer_relations_organizations_id_seq OWNED BY customer_relations_organizations.id; +CREATE TABLE dast_pre_scan_verifications ( + id bigint NOT NULL, + dast_profile_id bigint NOT NULL, + ci_pipeline_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + status smallint DEFAULT 0 NOT NULL +); + +CREATE SEQUENCE dast_pre_scan_verifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE dast_pre_scan_verifications_id_seq OWNED BY dast_pre_scan_verifications.id; + CREATE TABLE dast_profile_schedules ( id bigint NOT NULL, project_id bigint NOT NULL, @@ -23720,6 +23738,8 @@ ALTER TABLE ONLY customer_relations_contacts ALTER COLUMN id SET DEFAULT nextval ALTER TABLE ONLY customer_relations_organizations ALTER COLUMN id SET DEFAULT nextval('customer_relations_organizations_id_seq'::regclass); +ALTER TABLE ONLY dast_pre_scan_verifications ALTER COLUMN id SET DEFAULT nextval('dast_pre_scan_verifications_id_seq'::regclass); + ALTER TABLE ONLY dast_profile_schedules ALTER COLUMN id SET DEFAULT nextval('dast_profile_schedules_id_seq'::regclass); ALTER TABLE ONLY dast_profiles ALTER COLUMN id SET DEFAULT nextval('dast_profiles_id_seq'::regclass); @@ -25551,6 +25571,9 @@ ALTER TABLE ONLY customer_relations_contacts ALTER TABLE ONLY customer_relations_organizations ADD CONSTRAINT customer_relations_organizations_pkey PRIMARY KEY (id); +ALTER TABLE ONLY dast_pre_scan_verifications + ADD CONSTRAINT dast_pre_scan_verifications_pkey PRIMARY KEY (id); + ALTER TABLE ONLY dast_profile_schedules ADD CONSTRAINT dast_profile_schedules_pkey PRIMARY KEY (id); @@ -28777,6 +28800,10 @@ CREATE UNIQUE INDEX index_cycle_analytics_stage_event_hashes_on_hash_sha_256 ON CREATE UNIQUE INDEX index_daily_build_group_report_results_unique_columns ON ci_daily_build_group_report_results USING btree (project_id, ref_path, date, group_name); +CREATE UNIQUE INDEX index_dast_pre_scan_verifications_on_ci_pipeline_id ON dast_pre_scan_verifications USING btree (ci_pipeline_id); + +CREATE INDEX index_dast_pre_scan_verifications_on_dast_profile_id ON dast_pre_scan_verifications USING btree (dast_profile_id); + CREATE INDEX index_dast_profile_schedules_active_next_run_at ON dast_profile_schedules USING btree (active, next_run_at); CREATE UNIQUE INDEX index_dast_profile_schedules_on_dast_profile_id ON dast_profile_schedules USING btree (dast_profile_id); @@ -35186,6 +35213,9 @@ ALTER TABLE ONLY fork_network_members ALTER TABLE ONLY security_orchestration_policy_rule_schedules ADD CONSTRAINT fk_rails_efe1d9b133 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY dast_pre_scan_verifications + ADD CONSTRAINT fk_rails_f08d9312a8 FOREIGN KEY (dast_profile_id) REFERENCES dast_profiles(id) ON DELETE CASCADE; + ALTER TABLE ONLY prometheus_alerts ADD CONSTRAINT fk_rails_f0e8db86aa FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/administration/application_settings_cache.md b/doc/administration/application_settings_cache.md index d04056beb96..30019df44aa 100644 --- a/doc/administration/application_settings_cache.md +++ b/doc/administration/application_settings_cache.md @@ -20,7 +20,7 @@ To change the expiry value: ::Tabs -:::TabTitle Omnibus package +:::TabTitle Linux package (Omnibus) 1. Edit `/etc/gitlab/gitlab.rb`: @@ -36,7 +36,7 @@ To change the expiry value: gitlab-ctl restart ``` -:::TabTitle Source +:::TabTitle Self-compiled (Source) 1. Edit `config/gitlab.yml`: diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index b1f9d6ceae1..be6d0d69a9a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -850,6 +850,25 @@ Input type: `AuditEventsStreamingDestinationEventsAddInput` | <a id="mutationauditeventsstreamingdestinationeventsadderrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationauditeventsstreamingdestinationeventsaddeventtypefilters"></a>`eventTypeFilters` | [`[String!]`](#string) | Event type filters present. | +### `Mutation.auditEventsStreamingDestinationEventsRemove` + +Input type: `AuditEventsStreamingDestinationEventsRemoveInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationauditeventsstreamingdestinationeventsremoveclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationauditeventsstreamingdestinationeventsremovedestinationid"></a>`destinationId` | [`AuditEventsExternalAuditEventDestinationID!`](#auditeventsexternalauditeventdestinationid) | Destination URL. | +| <a id="mutationauditeventsstreamingdestinationeventsremoveeventtypefilters"></a>`eventTypeFilters` | [`[String!]!`](#string) | List of event type filters to remove from streaming. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationauditeventsstreamingdestinationeventsremoveclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationauditeventsstreamingdestinationeventsremoveerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.auditEventsStreamingHeadersCreate` Input type: `AuditEventsStreamingHeadersCreateInput` diff --git a/doc/development/geo.md b/doc/development/geo.md index 884c09cc174..10747ea170e 100644 --- a/doc/development/geo.md +++ b/doc/development/geo.md @@ -588,23 +588,45 @@ When some write actions are not allowed because the site is a The database itself will already be read-only in a replicated setup, so we don't need to take any extra step for that. -## Steps needed to replicate a new data type - -As GitLab evolves, we constantly need to add new resources to the Geo replication system. -The implementation depends on resource specifics, but there are several things -that need to be taken care of: - -- Event generation on the primary site. Whenever a new resource is changed/updated, we need to - create a task for the Log Cursor. -- Event handling. The Log Cursor needs to have a handler for every event type generated by the primary site. -- Dispatch worker (cron job). Make sure the backfill condition works well. -- Sync worker. -- Registry with all possible states. -- Verification. -- Cleaner. When sync settings are changed for the secondary site, some resources need to be cleaned up. -- Geo Node Status. We need to provide API endpoints as well as some presentation in the GitLab Admin Area. -- Health Check. If we can perform some pre-cheсks and make site unhealthy if something is wrong, we should do that. - The `rake gitlab:geo:check` command has to be updated too. +## Ensuring a new feature has Geo support + +Geo depends on PostgreSQL replication of the main and CI databases, so if you add a new table or field, it should already work on secondary Geo sites. + +However, if you introduce a new kind of data which is stored outside of the main and CI PostgreSQL databases, then you need to ensure that this data is replicated and verified by Geo. This is necessary for customers to be able to rely on their secondary sites for [disaster recovery](../administration/geo/disaster_recovery/index.md). + +The following subsections describe how to determine whether work is needed, and if so, how to proceed. If you have any questions, [contact the Geo team](https://about.gitlab.com/handbook/product/categories/#geo-group). + +For comparison with your own features, see [Supported Geo data types](../administration/geo/replication/datatypes.md). It has a detailed, up-to-date list of the types of data that Geo replicates and verifies. + +### Git repositories + +If you add a feature that is backed by Git repositories, then you must add Geo support. See [the repository replicator strategy of the Geo self-service framework](geo/framework.md#repository-replicator-strategy). + +### Blobs + +If you add a subclass of `CarrierWave::Uploader::Base`, then you are adding what Geo calls a blob. If you specifically subclass [`AttachmentUploader` as generally recommended](uploads/working_with_uploads.md#recommendations), then the data has Geo support with no work needed. This is because `AttachmentUploader` tracks blobs with the `Upload` model using the `uploads` table, and Geo support is already implemented for that model. + +If your blobs are tracked in a new table, perhaps because you expect millions of rows at GitLab.com scale, then you must add Geo support. See [the blob replicator strategy of the Geo self-service framework](geo/framework.md#blob-replicator-strategy). + +[Geo detects new blobs with a spec](https://gitlab.com/gitlab-org/gitlab/-/blob/eeba0e4d231ae39012a5bbaeac43a72c2bd8affb/ee/spec/uploaders/every_gitlab_uploader_spec.rb) that fails when an `Uploader` does not have a corresponding `Replicator`. + +### Features with more than one kind of data + +If a new complex feature is backed by multiple kinds of data, for example, a Git repository and a blob, then you can likely consider each kind of data separately. + +Taking [Designs](../user/project/issues/design_management.md) as an example, each issue has a Git repository which can have many LFS objects, and each LFS object may have an automatically generated thumbnail. + +- LFS objects were already supported by Geo, so no Geo-specific work was needed. +- The implementation of thumbnails reused the `Upload` model, so no Geo-specific work was needed. +- Design Git repositories were not inherently supported by Geo, so work was needed. + +As another example, [Dependency Proxy](../administration/packages/dependency_proxy.md) is backed by two kinds of blobs, `DependencyProxy::Blob` and `DependencyProxy::Manifest`. We can use [the blob replicator strategy of the Geo self-service framework](geo/framework.md#blob-replicator-strategy) on each type, independent of each other. + +### Other kinds of data + +If a new feature introduces a new kind of data which is not a Git repository, or a blob, or a combination of the two, then contact the Geo team to discuss how to handle it. + +As an example, Container Registry data does not easily fit into the above categories. It is backed by a registry service which owns the data, and GitLab interacts with the registry service's API. So a one off approach is required for Geo support of Container Registry. Still, we are able to reuse much of the glue code of [the Geo self-service framework](geo/framework.md#repository-replicator-strategy). ## History of communication channel diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index af039c8a009..fc148e546de 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -53,7 +53,7 @@ To change these settings: ::Tabs - :::TabTitle Omnibus + :::TabTitle Linux package (Omnibus) 1. Edit `/etc/gitlab/gitlab.rb` and update the following section: @@ -73,7 +73,7 @@ To change these settings: sudo gitlab-ctl reconfigure ``` - :::TabTitle Helm chart + :::TabTitle Helm chart (Kubernetes) 1. Export the Helm values: @@ -102,7 +102,7 @@ To change these settings: helm upgrade -f gitlab_values.yaml gitlab gitlab/gitlab ``` - :::TabTitle Source + :::TabTitle Self-compiled (Source) 1. Open the configuration file: diff --git a/doc/subscriptions/gitlab_com/index.md b/doc/subscriptions/gitlab_com/index.md index 49a03e7285d..02810545409 100644 --- a/doc/subscriptions/gitlab_com/index.md +++ b/doc/subscriptions/gitlab_com/index.md @@ -102,9 +102,16 @@ To view a list of seats being used: 1. On the left sidebar, select **Settings > Usage Quotas**. 1. On the **Seats** tab, view usage information. -The seat usage listing is updated live, but the usage statistics on the billing page are updated -only once per day. For this reason there can be a minor difference between the seat usage listing -and the billing page. +The data in seat usage listing, **Seats in use**, and **Seats in subscription** are updated live. +The counts for **Max seats used** and **Seats owed** are updated once per day. + +To view your subscription information and a summary of seat counts: + +1. On the top bar, select **Main menu > Groups** and find your group. +1. On the left sidebar, select **Settings > Billing**. + +The usage statistics are updated once per day, which may cause +a difference between the information in the **Usage Quotas** page and the **Billing page**. ### Search seat usage diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index c2952b23615..234faa893eb 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -463,6 +463,7 @@ You can edit the following issue attributes in the right sidebar: - Confidentiality - Due date - [Epic](../group/epics/index.md) +- [Health status](issues/managing_issues.md#health-status) - [Iteration](../group/iterations/index.md) - Labels - Milestone diff --git a/lib/api/api.rb b/lib/api/api.rb index 0816d3f182d..0e49103a880 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -228,7 +228,9 @@ module API mount ::API::Metadata mount ::API::Metrics::Dashboard::Annotations mount ::API::Metrics::UserStarredDashboards + mount ::API::Namespaces mount ::API::PackageFiles + mount ::API::Pages mount ::API::PersonalAccessTokens::SelfInformation mount ::API::PersonalAccessTokens mount ::API::ProjectClusters @@ -297,14 +299,12 @@ module API mount ::API::MavenPackages mount ::API::Members mount ::API::MergeRequests - mount ::API::Namespaces mount ::API::Notes mount ::API::NotificationSettings mount ::API::NpmInstancePackages mount ::API::NpmProjectPackages mount ::API::NugetGroupPackages mount ::API::NugetProjectPackages - mount ::API::Pages mount ::API::PagesDomains mount ::API::ProjectContainerRepositories mount ::API::ProjectDebianDistributions diff --git a/lib/api/entities/namespace.rb b/lib/api/entities/namespace.rb index f11303d41a6..15bc7d158c4 100644 --- a/lib/api/entities/namespace.rb +++ b/lib/api/entities/namespace.rb @@ -3,7 +3,7 @@ module API module Entities class Namespace < Entities::NamespaceBasic - expose :members_count_with_descendants, if: -> (namespace, opts) { expose_members_count_with_descendants?(namespace, opts) } do |namespace, _| + expose :members_count_with_descendants, documentation: { type: 'integer', example: 5 }, if: -> (namespace, opts) { expose_members_count_with_descendants?(namespace, opts) } do |namespace, _| namespace.users_with_descendants.count end diff --git a/lib/api/entities/namespace_basic.rb b/lib/api/entities/namespace_basic.rb index 2b9dd0b5f4d..4264326cdc2 100644 --- a/lib/api/entities/namespace_basic.rb +++ b/lib/api/entities/namespace_basic.rb @@ -3,9 +3,15 @@ module API module Entities class NamespaceBasic < Grape::Entity - expose :id, :name, :path, :kind, :full_path, :parent_id, :avatar_url + expose :id, documentation: { type: 'integer', example: 2 } + expose :name, documentation: { type: 'string', example: 'project' } + expose :path, documentation: { type: 'string', example: 'my_project' } + expose :kind, documentation: { type: 'string', example: 'project' } + expose :full_path, documentation: { type: 'string', example: 'group/my_project' } + expose :parent_id, documentation: { type: 'integer', example: 1 } + expose :avatar_url, documentation: { type: 'string', example: 'https://example.com/avatar/12345' } - expose :web_url do |namespace| + expose :web_url, documentation: { type: 'string', example: 'https://example.com/group/my_project' } do |namespace| if namespace.user_namespace? Gitlab::Routing.url_helpers.user_url(namespace.owner) else diff --git a/lib/api/entities/namespace_existence.rb b/lib/api/entities/namespace_existence.rb index d93078ecdac..ac9511930ab 100644 --- a/lib/api/entities/namespace_existence.rb +++ b/lib/api/entities/namespace_existence.rb @@ -3,7 +3,8 @@ module API module Entities class NamespaceExistence < Grape::Entity - expose :exists, :suggests + expose :exists, documentation: { type: 'boolean' } + expose :suggests, documentation: { type: 'string', is_array: true, example: 'my-group1' } end end end diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index eeb66c86b3b..2b1007e715a 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -6,6 +6,8 @@ module API before { authenticate! } + NAMESPACES_TAGS = %w[namespaces].freeze + helpers do params :optional_list_params_ee do # EE::API::Namespaces would override this helper @@ -20,12 +22,18 @@ module API prepend_mod_with('API::Namespaces') # rubocop: disable Cop/InjectEnterpriseEditionModule resource :namespaces do - desc 'Get a namespaces list' do + desc 'List namespaces' do + detail 'Get a list of the namespaces of the authenticated user. If the user is an administrator, a list of all namespaces in the GitLab instance is shown.' success Entities::Namespace + failure [ + { code: 401, message: 'Unauthorized' } + ] + is_array true + tags NAMESPACES_TAGS end params do - optional :search, type: String, desc: "Search query for namespaces" - optional :owned_only, type: Boolean, desc: "Owned namespaces only" + optional :search, type: String, desc: 'Returns a list of namespaces the user is authorized to view based on the search criteria' + optional :owned_only, type: Boolean, desc: 'In GitLab 14.2 and later, returns a list of owned namespaces only' use :pagination use :optional_list_params_ee @@ -46,11 +54,17 @@ module API present paginate(namespaces), options.reverse_merge(custom_namespace_present_options) end - desc 'Get a namespace by ID' do + desc 'Get namespace by ID' do + detail 'Get a namespace by ID' success Entities::Namespace + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags NAMESPACES_TAGS end params do - requires :id, type: String, desc: "Namespace's ID or path" + requires :id, types: [String, Integer], desc: 'ID or URL-encoded path of the namespace' end get ':id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS, feature_category: :subgroups, urgency: :low do user_namespace = find_namespace!(params[:id]) @@ -58,12 +72,17 @@ module API present user_namespace, with: Entities::Namespace, current_user: current_user end - desc 'Get existence of a namespace including alternative suggestions' do + desc 'Get existence of a namespace' do + detail 'Get existence of a namespace by path. Suggests a new namespace path that does not already exist.' success Entities::NamespaceExistence + failure [ + { code: 401, message: 'Unauthorized' } + ] + tags NAMESPACES_TAGS end params do - requires :namespace, type: String, desc: "Namespace's path" - optional :parent_id, type: Integer, desc: "The ID of the parent namespace. If no ID is specified, only top-level namespaces are considered." + requires :namespace, type: String, desc: "Namespace’s path" + optional :parent_id, type: Integer, desc: 'The ID of the parent namespace. If no ID is specified, only top-level namespaces are considered.' end get ':namespace/exists', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS, feature_category: :subgroups, urgency: :low do check_rate_limit!(:namespace_exists, scope: current_user) diff --git a/lib/api/pages.rb b/lib/api/pages.rb index 7e230bd3c67..0cedf7d975f 100644 --- a/lib/api/pages.rb +++ b/lib/api/pages.rb @@ -10,11 +10,18 @@ module API end params do - requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' + requires :id, types: [String, Integer], + desc: 'The ID or URL-encoded path of the project owned by the authenticated user' end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Unpublish pages' do - detail 'This feature was introduced in GitLab 12.6' + detail 'Remove pages. The user must have administrator access. This feature was introduced in GitLab 12.6' + success code: 204 + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not Found' } + ] + tags %w[pages] end delete ':id/pages' do authorize! :remove_pages, user_project diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index bf6ebb21f7d..704bd929595 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -156,6 +156,7 @@ csv_issue_imports: :gitlab_main custom_emoji: :gitlab_main customer_relations_contacts: :gitlab_main customer_relations_organizations: :gitlab_main +dast_pre_scan_verifications: :gitlab_main dast_profile_schedules: :gitlab_main dast_profiles: :gitlab_main dast_profiles_pipelines: :gitlab_main diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 16416dd2507..be46480a716 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -10,6 +10,7 @@ module Gitlab include Migrations::TimeoutHelpers include Migrations::ConstraintsHelpers include Migrations::ExtensionHelpers + include Migrations::SidekiqHelpers include DynamicModelHelpers include RenameTableHelpers include AsyncIndexes::MigrationHelpers @@ -1027,38 +1028,6 @@ module Gitlab rescue ArgumentError end - # Remove any instances of deprecated job classes lingering in queues. - # - # rubocop:disable Cop/SidekiqApiUsage - def sidekiq_remove_jobs(job_klass:) - Sidekiq::Queue.new(job_klass.queue).each do |job| - job.delete if job.klass == job_klass.to_s - end - - Sidekiq::RetrySet.new.each do |retri| - retri.delete if retri.klass == job_klass.to_s - end - - Sidekiq::ScheduledSet.new.each do |scheduled| - scheduled.delete if scheduled.klass == job_klass.to_s - end - end - # rubocop:enable Cop/SidekiqApiUsage - - def sidekiq_queue_migrate(queue_from, to:) - while sidekiq_queue_length(queue_from) > 0 - Sidekiq.redis do |conn| - conn.rpoplpush "queue:#{queue_from}", "queue:#{to}" - end - end - end - - def sidekiq_queue_length(queue_name) - Sidekiq.redis do |conn| - conn.llen("queue:#{queue_name}") - end - end - def check_trigger_permissions!(table) unless Grant.create_and_execute_trigger?(table) dbname = ApplicationRecord.database.database_name diff --git a/lib/gitlab/database/migrations/sidekiq_helpers.rb b/lib/gitlab/database/migrations/sidekiq_helpers.rb new file mode 100644 index 00000000000..c536b33bbdf --- /dev/null +++ b/lib/gitlab/database/migrations/sidekiq_helpers.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module Migrations + # rubocop:disable Cop/SidekiqApiUsage + # rubocop:disable Cop/SidekiqRedisCall + module SidekiqHelpers + # Constants for default sidekiq_remove_jobs values + DEFAULT_MAX_ATTEMPTS = 5 + DEFAULT_TIMES_IN_A_ROW = 2 + + # Probabilistically removes job_klasses from their specific queues, the + # retry set and the scheduled set. + # + # If jobs are still being processed at the same time, then there is a + # small chance it will not remove all instances of job_klass. To + # minimize this risk, it repeatedly removes matching jobs from each + # until nothing is removed twice in a row. + # + # Before calling this method, you should make sure that job_klass is no + # longer being scheduled within the running application. + def sidekiq_remove_jobs( + job_klasses:, + times_in_a_row: DEFAULT_TIMES_IN_A_ROW, + max_attempts: DEFAULT_MAX_ATTEMPTS + ) + + kwargs = { times_in_a_row: times_in_a_row, max_attempts: max_attempts } + + job_klasses_queues = job_klasses + .select { |job_klass| job_klass.to_s.safe_constantize.present? } + .map { |job_klass| job_klass.safe_constantize.queue } + .uniq + + job_klasses_queues.each do |queue| + delete_jobs_for( + set: Sidekiq::Queue.new(queue), + job_klasses: job_klasses, + kwargs: kwargs + ) + end + + delete_jobs_for( + set: Sidekiq::RetrySet.new, + kwargs: kwargs, + job_klasses: job_klasses + ) + + delete_jobs_for( + set: Sidekiq::ScheduledSet.new, + kwargs: kwargs, + job_klasses: job_klasses + ) + end + + def sidekiq_queue_migrate(queue_from, to:) + while sidekiq_queue_length(queue_from) > 0 + Sidekiq.redis do |conn| + conn.rpoplpush "queue:#{queue_from}", "queue:#{to}" + end + end + end + + def sidekiq_queue_length(queue_name) + Sidekiq.redis do |conn| + conn.llen("queue:#{queue_name}") + end + end + + private + + # Handle the "jobs deleted" tracking that is needed in order to track + # whether a job was deleted or not. + def delete_jobs_for(set:, kwargs:, job_klasses:) + until_equal_to(0, **kwargs) do + set.count do |job| + job_klasses.include?(job.klass) && job.delete + end + end + end + + # Control how many times in a row you want to see a job deleted 0 + # times. The idea is that if you see 0 jobs deleted x number of times + # in a row you've *likely* covered the case in which the queue was + # mutating while this was running. + def until_equal_to(target, times_in_a_row:, max_attempts:) + streak = 0 + + result = { attempts: 0, success: false } + + 1.upto(max_attempts) do |current_attempt| + # yield's return value is a count of "jobs_deleted" + if yield == target + streak += 1 + elsif streak > 0 + streak = 0 + end + + result[:attempts] = current_attempt + result[:success] = streak == times_in_a_row + + break if result[:success] + end + result + end + end + # rubocop:enable Cop/SidekiqApiUsage + # rubocop:enable Cop/SidekiqRedisCall + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fdab41e0ecd..3995ce97c92 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11190,6 +11190,9 @@ msgstr "" msgid "Couldn't assign policy to project or group" msgstr "" +msgid "Couldn't find event type filters where audit event type(s): %{missing_filters}" +msgstr "" + msgid "Country" msgstr "" @@ -23660,6 +23663,9 @@ msgstr "" msgid "Key (PEM)" msgstr "" +msgid "Key result" +msgstr "" + msgid "Key:" msgstr "" @@ -28030,9 +28036,24 @@ msgstr "" msgid "OK" msgstr "" +msgid "OKR|Existing key result" +msgstr "" + +msgid "OKR|Existing objective" +msgstr "" + +msgid "OKR|New key result" +msgstr "" + +msgid "OKR|New objective" +msgstr "" + msgid "Object does not exist on the server or you don't have permissions to access it" msgstr "" +msgid "Objective" +msgstr "" + msgid "Observability" msgstr "" @@ -46326,6 +46347,9 @@ msgstr[1] "" msgid "WorkItem|Cancel" msgstr "" +msgid "WorkItem|Child objectives and key results" +msgstr "" + msgid "WorkItem|Child removed" msgstr "" @@ -46386,12 +46410,18 @@ msgstr "" msgid "WorkItem|No milestone" msgstr "" +msgid "WorkItem|No objectives or key results are currently assigned." +msgstr "" + msgid "WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts." msgstr "" msgid "WorkItem|None" msgstr "" +msgid "WorkItem|Objective" +msgstr "" + msgid "WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task." msgstr "" diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 7e35c39cd48..69f5992a80e 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -146,6 +146,20 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(false); }); + it('does not render SidebarHealthStatusWidget', async () => { + const SidebarHealthStatusWidget = ( + await import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue') + ).default; + expect(wrapper.findComponent(SidebarHealthStatusWidget).exists()).toBe(false); + }); + + it('does not render SidebarWeightWidget', async () => { + const SidebarWeightWidget = ( + await import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue') + ).default; + expect(wrapper.findComponent(SidebarWeightWidget).exists()).toBe(false); + }); + describe('when we emit close', () => { let toggleBoardItem; diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 407bd60b2b7..d34fc0c1e61 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -812,14 +812,30 @@ describe('ReadyToMerge', () => { ); }); - it('shows the diverged commits text when the source branch is behind the target', () => { - createComponent({ - mr: { divergedCommitsCount: 9001, userPermissions: { canMerge: false }, canMerge: false }, + describe('shows the diverged commits text when the source branch is behind the target', () => { + it('when the MR can be merged', () => { + createComponent({ + mr: { divergedCommitsCount: 9001 }, + }); + + expect(wrapper.text()).toEqual( + expect.stringContaining('The source branch is 9001 commits behind the target branch'), + ); }); - expect(wrapper.text()).toEqual( - expect.stringContaining('The source branch is 9001 commits behind the target branch'), - ); + it('when the MR cannot be merged', () => { + createComponent({ + mr: { + divergedCommitsCount: 9001, + userPermissions: { canMerge: false }, + canMerge: false, + }, + }); + + expect(wrapper.text()).toEqual( + expect.stringContaining('The source branch is 9001 commits behind the target branch'), + ); + }); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js new file mode 100644 index 00000000000..5563ba12a45 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js @@ -0,0 +1,34 @@ +import { GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +const createComponent = () => { + return extendedWrapper(shallowMount(OkrActionsSplitButton)); +}; + +describe('RelatedItemsTree', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('OkrActionsSplitButton', () => { + describe('template', () => { + it('renders objective and key results sections', () => { + expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(0).text()).toContain( + 'Objective', + ); + expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(1).text()).toContain( + 'Key result', + ); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js new file mode 100644 index 00000000000..0c5f2af1209 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -0,0 +1,45 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; +import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; + +describe('WorkItemTree', () => { + let wrapper; + + const findToggleButton = () => wrapper.findByTestId('toggle-tree'); + const findTreeBody = () => wrapper.findByTestId('tree-body'); + const findEmptyState = () => wrapper.findByTestId('tree-empty'); + const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); + + const createComponent = () => { + wrapper = shallowMountExtended(WorkItemTree, { + propsData: { workItemType: 'Objective' }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('is expanded by default and displays Add button', () => { + expect(findToggleButton().props('icon')).toBe('chevron-lg-up'); + expect(findTreeBody().exists()).toBe(true); + expect(findToggleFormSplitButton().exists()).toBe(true); + }); + + it('collapses on click toggle button', async () => { + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleButton().props('icon')).toBe('chevron-lg-down'); + expect(findTreeBody().exists()).toBe(false); + }); + + it('displays empty state if there are no children', () => { + expect(findEmptyState().exists()).toBe(true); + }); +}); diff --git a/spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb index ab5a360d908..7f70a4cfc4e 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do - around do |example| - freeze_time { example.run } + before_all do + freeze_time end let(:params) { { from: 1.year.ago, current_user: user } } diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 65fbc8d9935..536bbb3d78a 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -2006,170 +2006,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end - describe 'sidekiq migration helpers', :redis do - let(:worker) do - Class.new do - include Sidekiq::Worker - - sidekiq_options queue: 'test' - - def self.name - 'WorkerClass' - end - end - end - - let(:same_queue_different_worker) do - Class.new do - include Sidekiq::Worker - - sidekiq_options queue: 'test' - - def self.name - 'SameQueueDifferentWorkerClass' - end - end - end - - let(:unrelated_worker) do - Class.new do - include Sidekiq::Worker - - sidekiq_options queue: 'unrelated' - - def self.name - 'UnrelatedWorkerClass' - end - end - end - - before do - stub_const(worker.name, worker) - stub_const(unrelated_worker.name, unrelated_worker) - stub_const(same_queue_different_worker.name, same_queue_different_worker) - end - - describe '#sidekiq_remove_jobs', :clean_gitlab_redis_queues do - def clear_queues - Sidekiq::Queue.new('test').clear - Sidekiq::Queue.new('unrelated').clear - Sidekiq::RetrySet.new.clear - Sidekiq::ScheduledSet.new.clear - end - - around do |example| - clear_queues - Sidekiq::Testing.disable!(&example) - clear_queues - end - - it "removes all related job instances from the job class's queue" do - worker.perform_async - same_queue_different_worker.perform_async - unrelated_worker.perform_async - - queue_we_care_about = Sidekiq::Queue.new(worker.queue) - unrelated_queue = Sidekiq::Queue.new(unrelated_worker.queue) - - expect(queue_we_care_about.size).to eq(2) - expect(unrelated_queue.size).to eq(1) - - model.sidekiq_remove_jobs(job_klass: worker) - - expect(queue_we_care_about.size).to eq(1) - expect(queue_we_care_about.map(&:klass)).not_to include(worker.name) - expect(queue_we_care_about.map(&:klass)).to include( - same_queue_different_worker.name - ) - expect(unrelated_queue.size).to eq(1) - end - - context 'when job instances are in the scheduled set' do - it 'removes all related job instances from the scheduled set' do - worker.perform_in(1.hour) - unrelated_worker.perform_in(1.hour) - - scheduled = Sidekiq::ScheduledSet.new - - expect(scheduled.size).to eq(2) - expect(scheduled.map(&:klass)).to include( - worker.name, - unrelated_worker.name - ) - - model.sidekiq_remove_jobs(job_klass: worker) - - expect(scheduled.size).to eq(1) - expect(scheduled.map(&:klass)).not_to include(worker.name) - expect(scheduled.map(&:klass)).to include(unrelated_worker.name) - end - end - - context 'when job instances are in the retry set' do - include_context 'when handling retried jobs' - - it 'removes all related job instances from the retry set' do - retry_in(worker, 1.hour) - retry_in(worker, 2.hours) - retry_in(worker, 3.hours) - retry_in(unrelated_worker, 4.hours) - - retries = Sidekiq::RetrySet.new - - expect(retries.size).to eq(4) - expect(retries.map(&:klass)).to include( - worker.name, - unrelated_worker.name - ) - - model.sidekiq_remove_jobs(job_klass: worker) - - expect(retries.size).to eq(1) - expect(retries.map(&:klass)).not_to include(worker.name) - expect(retries.map(&:klass)).to include(unrelated_worker.name) - end - end - end - - describe '#sidekiq_queue_length' do - context 'when queue is empty' do - it 'returns zero' do - Sidekiq::Testing.disable! do - expect(model.sidekiq_queue_length('test')).to eq 0 - end - end - end - - context 'when queue contains jobs' do - it 'returns correct size of the queue' do - Sidekiq::Testing.disable! do - worker.perform_async('Something', [1]) - worker.perform_async('Something', [2]) - - expect(model.sidekiq_queue_length('test')).to eq 2 - end - end - end - end - - describe '#sidekiq_queue_migrate' do - it 'migrates jobs from one sidekiq queue to another' do - Sidekiq::Testing.disable! do - worker.perform_async('Something', [1]) - worker.perform_async('Something', [2]) - - expect(model.sidekiq_queue_length('test')).to eq 2 - expect(model.sidekiq_queue_length('new_test')).to eq 0 - - model.sidekiq_queue_migrate('test', to: 'new_test') - - expect(model.sidekiq_queue_length('test')).to eq 0 - expect(model.sidekiq_queue_length('new_test')).to eq 2 - end - end - end - end - describe '#check_trigger_permissions!' do it 'does nothing when the user has the correct permissions' do expect { model.check_trigger_permissions!('users') } diff --git a/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb b/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb new file mode 100644 index 00000000000..fb1cb46171f --- /dev/null +++ b/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Gitlab::Database::Migrations::SidekiqHelpers do + let(:model) do + ActiveRecord::Migration.new.extend(described_class) + end + + describe "sidekiq migration helpers", :redis do + let(:worker) do + Class.new do + include Sidekiq::Worker + + sidekiq_options queue: "test" + + def self.name + "WorkerClass" + end + end + end + + let(:worker_two) do + Class.new do + include Sidekiq::Worker + + sidekiq_options queue: "test_two" + + def self.name + "WorkerTwoClass" + end + end + end + + let(:same_queue_different_worker) do + Class.new do + include Sidekiq::Worker + + sidekiq_options queue: "test" + + def self.name + "SameQueueDifferentWorkerClass" + end + end + end + + let(:unrelated_worker) do + Class.new do + include Sidekiq::Worker + + sidekiq_options queue: "unrelated" + + def self.name + "UnrelatedWorkerClass" + end + end + end + + before do + stub_const(worker.name, worker) + stub_const(worker_two.name, worker_two) + stub_const(unrelated_worker.name, unrelated_worker) + stub_const(same_queue_different_worker.name, same_queue_different_worker) + end + + describe "#sidekiq_remove_jobs", :clean_gitlab_redis_queues do + def clear_queues + Sidekiq::Queue.new("test").clear + Sidekiq::Queue.new("test_two").clear + Sidekiq::Queue.new("unrelated").clear + Sidekiq::RetrySet.new.clear + Sidekiq::ScheduledSet.new.clear + end + + around do |example| + clear_queues + Sidekiq::Testing.disable!(&example) + clear_queues + end + + context "when the constant is not defined" do + it "doesn't try to delete it" do + my_non_constant = +"SomeThingThatIsNotAConstant" + + expect(Sidekiq::Queue).not_to receive(:new).with(any_args) + model.sidekiq_remove_jobs(job_klasses: [my_non_constant]) + end + end + + context "when the constant is defined" do + it "will use it find job instances to delete" do + my_constant = worker.name + expect(Sidekiq::Queue) + .to receive(:new) + .with(worker.queue) + .and_call_original + model.sidekiq_remove_jobs(job_klasses: [my_constant]) + end + end + + it "removes all related job instances from the job classes' queues" do + worker.perform_async + worker_two.perform_async + same_queue_different_worker.perform_async + unrelated_worker.perform_async + + worker_queue = Sidekiq::Queue.new(worker.queue) + worker_two_queue = Sidekiq::Queue.new(worker_two.queue) + unrelated_queue = Sidekiq::Queue.new(unrelated_worker.queue) + + expect(worker_queue.size).to eq(2) + expect(worker_two_queue.size).to eq(1) + expect(unrelated_queue.size).to eq(1) + + model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name]) + + expect(worker_queue.size).to eq(1) + expect(worker_two_queue.size).to eq(0) + expect(worker_queue.map(&:klass)).not_to include(worker.name) + expect(worker_queue.map(&:klass)).to include( + same_queue_different_worker.name + ) + expect(worker_two_queue.map(&:klass)).not_to include(worker_two.name) + expect(unrelated_queue.size).to eq(1) + end + + context "when job instances are in the scheduled set" do + it "removes all related job instances from the scheduled set" do + worker.perform_in(1.hour) + worker_two.perform_in(1.hour) + unrelated_worker.perform_in(1.hour) + + scheduled = Sidekiq::ScheduledSet.new + + expect(scheduled.size).to eq(3) + expect(scheduled.map(&:klass)).to include( + worker.name, + worker_two.name, + unrelated_worker.name + ) + + model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name]) + + expect(scheduled.size).to eq(1) + expect(scheduled.map(&:klass)).not_to include(worker.name) + expect(scheduled.map(&:klass)).not_to include(worker_two.name) + expect(scheduled.map(&:klass)).to include(unrelated_worker.name) + end + end + + context "when job instances are in the retry set" do + include_context "when handling retried jobs" + + it "removes all related job instances from the retry set" do + retry_in(worker, 1.hour) + retry_in(worker, 2.hours) + retry_in(worker, 3.hours) + retry_in(worker_two, 4.hours) + retry_in(unrelated_worker, 5.hours) + + retries = Sidekiq::RetrySet.new + + expect(retries.size).to eq(5) + expect(retries.map(&:klass)).to include( + worker.name, + worker_two.name, + unrelated_worker.name + ) + + model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name]) + + expect(retries.size).to eq(1) + expect(retries.map(&:klass)).not_to include(worker.name) + expect(retries.map(&:klass)).not_to include(worker_two.name) + expect(retries.map(&:klass)).to include(unrelated_worker.name) + end + end + + # Imitate job deletion returning zero and then non zero. + context "when job fails to be deleted" do + let(:job_double) do + instance_double( + "Sidekiq::JobRecord", + klass: worker.name + ) + end + + context "and does not work enough times in a row before max attempts" do + it "tries the max attempts without succeeding" do + worker.perform_async + + allow(job_double).to receive(:delete).and_return(true) + + # Scheduled set runs last so only need to stub out its values. + allow(Sidekiq::ScheduledSet) + .to receive(:new) + .and_return([job_double]) + + expect(model.sidekiq_remove_jobs(job_klasses: [worker.name])) + .to eq( + { + attempts: 5, + success: false + } + ) + end + end + + context "and then it works enough times in a row before max attempts" do + it "succeeds" do + worker.perform_async + + # attempt 1: false will increment the streak once to 1 + # attempt 2: true resets it back to 0 + # attempt 3: false will increment the streak once to 1 + # attempt 4: false will increment the streak once to 2, loop breaks + allow(job_double).to receive(:delete).and_return(false, true, false) + + worker.perform_async + + # Scheduled set runs last so only need to stub out its values. + allow(Sidekiq::ScheduledSet) + .to receive(:new) + .and_return([job_double]) + + expect(model.sidekiq_remove_jobs(job_klasses: [worker.name])) + .to eq( + { + attempts: 4, + success: true + } + ) + end + end + end + end + + describe "#sidekiq_queue_length" do + context "when queue is empty" do + it "returns zero" do + Sidekiq::Testing.disable! do + expect(model.sidekiq_queue_length("test")).to eq 0 + end + end + end + + context "when queue contains jobs" do + it "returns correct size of the queue" do + Sidekiq::Testing.disable! do + worker.perform_async("Something", [1]) + worker.perform_async("Something", [2]) + + expect(model.sidekiq_queue_length("test")).to eq 2 + end + end + end + end + + describe "#sidekiq_queue_migrate" do + it "migrates jobs from one sidekiq queue to another" do + Sidekiq::Testing.disable! do + worker.perform_async("Something", [1]) + worker.perform_async("Something", [2]) + + expect(model.sidekiq_queue_length("test")).to eq 2 + expect(model.sidekiq_queue_length("new_test")).to eq 0 + + model.sidekiq_queue_migrate("test", to: "new_test") + + expect(model.sidekiq_queue_length("test")).to eq 0 + expect(model.sidekiq_queue_length("new_test")).to eq 2 + end + end + end + end +end diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index 04964d36dcd..ed55aca49b7 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -48,6 +48,23 @@ RSpec.describe UserDetail do describe '#website_url' do it { is_expected.to validate_length_of(:website_url).is_at_most(500) } + + it 'only validates the website_url if it is changed' do + user_detail = create(:user_detail) + # `update_attribute` required to bypass current validations + # Validations on `User#website_url` were added after + # there was already data in the database and `UserDetail#website_url` is + # derived from `User#website_url` so this reproduces the state of some of + # our production data + user_detail.update_attribute(:website_url, 'NotAUrl') + + expect(user_detail).to be_valid + + user_detail.website_url = 'AlsoNotAUrl' + + expect(user_detail).not_to be_valid + expect(user_detail.errors.full_messages).to match_array(["Website url is not a valid URL"]) + end end end |