Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-11-17 15:09:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-11-17 15:09:13 +0300
commit255831389a5080bb61242b3b50426918c4e1a5aa (patch)
treef5b888bc321828d7f607ed9cf25a98b2a78b1986
parent375c6d54dd85bfdf4be302c9cdac088a58b64c59 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue13
-rw-r--r--app/assets/javascripts/boards/stores/actions.js1
-rw-r--r--app/assets/javascripts/merge_request_tabs.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js (renamed from app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js)0
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue66
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue92
-rw-r--r--app/assets/javascripts/work_items/constants.js15
-rw-r--r--app/models/user_detail.rb2
-rw-r--r--config/gitlab_loose_foreign_keys.yml4
-rw-r--r--db/docs/dast_pre_scan_verifications.yml9
-rw-r--r--db/migrate/20221103205317_create_dast_pre_scan_verification.rb22
-rw-r--r--db/post_migrate/20221115120602_add_index_for_issues_health_status_ordering.rb23
-rw-r--r--db/schema_migrations/202211032053171
-rw-r--r--db/schema_migrations/202211151206021
-rw-r--r--db/structure.sql30
-rw-r--r--doc/administration/application_settings_cache.md4
-rw-r--r--doc/api/graphql/reference/index.md19
-rw-r--r--doc/development/geo.md56
-rw-r--r--doc/integration/omniauth.md6
-rw-r--r--doc/subscriptions/gitlab_com/index.md13
-rw-r--r--doc/user/project/issue_board.md1
-rw-r--r--lib/api/api.rb4
-rw-r--r--lib/api/entities/namespace.rb2
-rw-r--r--lib/api/entities/namespace_basic.rb10
-rw-r--r--lib/api/entities/namespace_existence.rb3
-rw-r--r--lib/api/namespaces.rb35
-rw-r--r--lib/api/pages.rb11
-rw-r--r--lib/gitlab/database/gitlab_schemas.yml1
-rw-r--r--lib/gitlab/database/migration_helpers.rb33
-rw-r--r--lib/gitlab/database/migrations/sidekiq_helpers.rb112
-rw-r--r--locale/gitlab.pot30
-rw-r--r--spec/frontend/boards/components/board_content_sidebar_spec.js14
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js28
-rw-r--r--spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js34
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js45
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb4
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb164
-rw-r--r--spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb276
-rw-r--r--spec/models/user_detail_spec.rb17
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>
+ &middot;
+ </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