From ae27cd3c8824d0d7815ad9ba550ad249f7e298a6 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 15 Sep 2021 12:11:13 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/docs.gitlab-ci.yml | 1 + .../javascripts/blob/blob_file_dropzone.js | 1 - .../javascripts/ide/services/terminals.js | 4 +- .../components/issuable_item.vue | 9 +- .../components/issues_list_app.vue | 50 ++-- app/assets/javascripts/issues_list/index.js | 10 +- .../queries/get_issues.query.graphql | 31 ++- .../queries/get_issues_counts.query.graphql | 50 +++- .../queries/issue.fragment.graphql | 1 + .../queries/iteration.fragment.graphql | 4 + .../queries/label.fragment.graphql | 6 + .../queries/milestone.fragment.graphql | 4 + .../queries/reorder_issues.mutation.graphql | 8 +- .../queries/search_iterations.query.graphql | 18 +- .../queries/search_labels.query.graphql | 18 +- .../queries/search_milestones.query.graphql | 16 +- .../queries/search_users.query.graphql | 20 +- .../issues_list/queries/user.fragment.graphql | 6 + .../javascripts/lib/utils/common_utils.js | 3 - .../components/milestone_combobox.vue | 3 +- .../javascripts/pages/groups/issues/index.js | 34 +-- .../ref/components/ref_selector.vue | 3 +- app/assets/javascripts/rest_api.js | 2 +- .../javascripts/sentry/sentry_config.js | 1 - .../render_identifier_instance_text.js | 2 +- .../stylesheets/framework/variables.scss | 2 +- app/assets/stylesheets/mailer.scss | 1 - .../page_bundles/merge_requests.scss | 2 +- app/controllers/groups_controller.rb | 1 + app/controllers/projects/issues_controller.rb | 2 +- app/graphql/mutations/custom_emoji/destroy.rb | 36 +++ app/graphql/types/mutation_type.rb | 1 + .../types/permission_types/custom_emoji.rb | 2 +- app/helpers/issues_helper.rb | 31 ++- app/models/ci/build.rb | 2 - app/models/issue.rb | 7 + app/models/project.rb | 1 + app/policies/custom_emoji_policy.rb | 10 + app/policies/group_policy.rb | 4 + .../import_export/project_export_presenter.rb | 1 - app/services/issue_rebalancing_service.rb | 136 ----------- .../relative_position_rebalancing_service.rb | 193 +++++++++++++++ app/views/groups/issues.html.haml | 55 +++-- app/views/projects/issues/index.html.haml | 4 +- app/workers/issue_rebalancing_worker.rb | 8 +- .../issue_rebalancing_optimization.yml | 8 - .../issue_rebalancing_with_retry.yml | 8 - .../development/roadmap_daterange_filter.yml | 8 - config/initializers/0_marginalia.rb | 2 +- config/known_invalid_graphql_queries.yml | 1 - config/sidekiq_queues.yml | 5 +- ...4444_create_vulnerability_finding_links.rb | 4 +- .../gitlab_rails_cheat_sheet.md | 2 +- doc/api/graphql/reference/index.md | 31 +++ .../strings_and_the_text_data_type.md | 32 +-- doc/development/database_review.md | 2 +- doc/development/migration_style_guide.md | 99 ++++---- doc/subscriptions/quarterly_reconciliation.md | 28 +++ doc/user/analytics/productivity_analytics.md | 6 +- doc/user/clusters/agent/index.md | 36 +-- ...tings-cluster-management-project-v12_5.png | Bin 37271 -> 0 bytes doc/user/clusters/management_project.md | 24 +- .../clusters/management_project_template.md | 94 +++++--- .../migrating_from_gma_to_project_template.md | 35 +-- .../group/value_stream_analytics/index.md | 2 +- .../certmanager.md | 6 - .../management_project_applications/cilium.md | 6 - .../elasticstack.md | 5 - .../management_project_applications/falco.md | 6 - .../fluentd.md | 6 - .../ingress.md | 5 - .../prometheus.md | 5 - .../management_project_applications/runner.md | 6 - .../management_project_applications/sentry.md | 6 - .../management_project_applications/vault.md | 6 - .../project/clusters/add_remove_clusters.md | 41 ++-- .../branch_push_merge_commit_analyzer.rb | 2 +- lib/gitlab/ci/templates/dotNET.gitlab-ci.yml | 2 +- lib/gitlab/ci/trace/stream.rb | 1 - lib/gitlab/database.rb | 30 ++- lib/gitlab/database/load_balancing.rb | 20 +- lib/gitlab/database/migration_helpers.rb | 1 + lib/gitlab/database/migration_helpers/v2.rb | 59 +++++ .../email/handler/create_issue_handler.rb | 2 +- .../email/handler/service_desk_handler.rb | 2 +- lib/gitlab/git.rb | 1 - lib/gitlab/issues/rebalancing/state.rb | 154 ++++++++++++ lib/gitlab/marginalia/comment.rb | 4 + lib/gitlab/pagination/keyset/order.rb | 2 +- lib/gitlab/quick_actions/issue_actions.rb | 2 +- lib/gitlab/slash_commands/issue_close.rb | 2 +- lib/gitlab/slash_commands/issue_move.rb | 4 +- lib/gitlab/slash_commands/issue_new.rb | 2 +- lib/gitlab/usage_data.rb | 4 +- lib/support/logrotate/gitlab | 1 - lib/tasks/gitlab/sidekiq.rake | 5 +- locale/gitlab.pot | 4 +- .../migration/add_limit_to_text_columns.rb | 27 ++- .../__helpers__/local_storage_helper.js | 4 +- .../mock_window_location_helper.js | 4 +- spec/frontend/ide/services/terminals_spec.js | 51 ++++ .../components/issues_list_app_spec.js | 101 +++++--- spec/frontend/issues_list/mock_data.js | 1 + spec/frontend/shortcuts_spec.js | 3 +- .../mutations/custom_emoji/destroy_spec.rb | 79 +++++++ spec/helpers/issues_helper_spec.rb | 33 ++- .../database/migration_helpers/v2_spec.rb | 29 +++ .../schema_migrations/context_spec.rb | 4 - spec/lib/gitlab/database_spec.rb | 36 ++- .../gitlab/issues/rebalancing/state_spec.rb | 223 ++++++++++++++++++ .../gitlab/pagination/keyset/order_spec.rb | 41 ++++ spec/lib/marginalia_spec.rb | 36 ++- spec/policies/custom_emoji_policy_spec.rb | 73 ++++++ .../mutations/custom_emoji/destroy_spec.rb | 73 ++++++ .../add_limit_to_text_columns_spec.rb | 36 ++- .../issue_rebalancing_service_spec.rb | 173 -------------- ...ative_position_rebalancing_service_spec.rb | 166 +++++++++++++ spec/spec_helper.rb | 1 + spec/support/database/multiple_databases.rb | 9 + spec/support/helpers/bare_repo_operations.rb | 1 - spec/workers/issue_rebalancing_worker_spec.rb | 28 +-- 121 files changed, 2024 insertions(+), 841 deletions(-) create mode 100644 app/assets/javascripts/issues_list/queries/iteration.fragment.graphql create mode 100644 app/assets/javascripts/issues_list/queries/label.fragment.graphql create mode 100644 app/assets/javascripts/issues_list/queries/milestone.fragment.graphql create mode 100644 app/assets/javascripts/issues_list/queries/user.fragment.graphql create mode 100644 app/graphql/mutations/custom_emoji/destroy.rb delete mode 100644 app/services/issue_rebalancing_service.rb create mode 100644 app/services/issues/relative_position_rebalancing_service.rb delete mode 100644 config/feature_flags/development/issue_rebalancing_optimization.yml delete mode 100644 config/feature_flags/development/issue_rebalancing_with_retry.yml delete mode 100644 config/feature_flags/development/roadmap_daterange_filter.yml delete mode 100644 doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png create mode 100644 lib/gitlab/issues/rebalancing/state.rb create mode 100644 spec/frontend/ide/services/terminals_spec.js create mode 100644 spec/graphql/mutations/custom_emoji/destroy_spec.rb create mode 100644 spec/lib/gitlab/issues/rebalancing/state_spec.rb create mode 100644 spec/policies/custom_emoji_policy_spec.rb create mode 100644 spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb delete mode 100644 spec/services/issue_rebalancing_service_spec.rb create mode 100644 spec/services/issues/relative_position_rebalancing_service_spec.rb create mode 100644 spec/support/database/multiple_databases.rb diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 3e3d994c70b..c585047f916 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -88,3 +88,4 @@ deprecations-doc check: needs: [] script: - bundle exec rake gitlab:docs:check_deprecations + allow_failure: true diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index 470c679b8ba..387d6043315 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -31,7 +31,6 @@ export default class BlobFileDropzone { autoProcessQueue: false, url: form.attr('action'), // Rails uses a hidden input field for PUT - // http://stackoverflow.com/questions/21056482/how-to-set-method-put-in-form-tag-in-rails method, clickable: true, uploadMultiple: false, diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js index ea54733baa4..99121948196 100644 --- a/app/assets/javascripts/ide/services/terminals.js +++ b/app/assets/javascripts/ide/services/terminals.js @@ -1,6 +1,8 @@ import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; -export const baseUrl = (projectPath) => `/${projectPath}/ide_terminals`; +export const baseUrl = (projectPath) => + joinPaths(gon.relative_url_root || '', `/${projectPath}/ide_terminals`); export const checkConfig = (projectPath, branch) => axios.post(`${baseUrl(projectPath)}/check_config`, { diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 29dd0b7fed5..df9d5c86a4b 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -69,6 +69,9 @@ export default { isIssuableUrlExternal() { return isExternal(this.webUrl); }, + reference() { + return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; + }, labels() { return this.issuable.labels?.nodes || this.issuable.labels || []; }, @@ -201,9 +204,9 @@ export default {
- {{ issuableSymbol }}{{ issuable.iid }} + + {{ reference }} + project?.issues.nodes ?? [], + update(data) { + return data[this.namespace]?.issues.nodes ?? []; + }, result({ data }) { - this.pageInfo = data.project?.issues.pageInfo ?? {}; + this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -204,7 +207,9 @@ export default { variables() { return this.queryVariables; }, - update: ({ project }) => project ?? {}, + update(data) { + return data[this.namespace] ?? {}; + }, error(error) { createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error }); }, @@ -220,8 +225,9 @@ export default { computed: { queryVariables() { return { - isSignedIn: this.isSignedIn, fullPath: this.fullPath, + isProject: this.isProject, + isSignedIn: this.isSignedIn, search: this.searchQuery, sort: this.sortKey, state: this.state, @@ -229,6 +235,9 @@ export default { ...this.apiFilterParams, }; }, + namespace() { + return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, hasSearch() { return this.searchQuery || Object.keys(this.urlFilterParams).length; }, @@ -242,7 +251,7 @@ export default { return this.state === IssuableStates.Opened; }, showCsvButtons() { - return this.isSignedIn; + return this.isProject && this.isSignedIn; }, apiFilterParams() { return convertToApiParams(this.filterTokens); @@ -447,39 +456,41 @@ export default { return this.$apollo .query({ query: searchLabelsQuery, - variables: { fullPath: this.fullPath, search }, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => data.project.labels.nodes); + .then(({ data }) => data[this.namespace]?.labels.nodes); }, fetchMilestones(search) { return this.$apollo .query({ query: searchMilestonesQuery, - variables: { fullPath: this.fullPath, search }, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => data.project.milestones.nodes); + .then(({ data }) => data[this.namespace]?.milestones.nodes); }, fetchIterations(search) { const id = Number(search); const variables = !search || Number.isNaN(id) - ? { fullPath: this.fullPath, search } - : { fullPath: this.fullPath, id }; + ? { fullPath: this.fullPath, search, isProject: this.isProject } + : { fullPath: this.fullPath, id, isProject: this.isProject }; return this.$apollo .query({ query: searchIterationsQuery, variables, }) - .then(({ data }) => data.project.iterations.nodes); + .then(({ data }) => data[this.namespace]?.iterations.nodes); }, fetchUsers(search) { return this.$apollo .query({ query: searchUsersQuery, - variables: { fullPath: this.fullPath, search }, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => data.project.projectMembers.nodes.map((member) => member.user)); + .then(({ data }) => + data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user), + ); }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; @@ -560,15 +571,16 @@ export default { } return axios - .put(joinPaths(this.issuesPath, issueToMove.iid, 'reorder'), { + .put(joinPaths(issueToMove.webPath, 'reorder'), { move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), + group_full_path: this.isProject ? undefined : this.fullPath, }) .then(() => { const serializedVariables = JSON.stringify(this.queryVariables); return this.$apollo.mutate({ mutation: reorderIssuesMutation, - variables: { oldIndex, newIndex, serializedVariables }, + variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables }, }); }) .catch((error) => { diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 03ab524d719..e89e3e8e681 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -85,17 +85,17 @@ export function mountIssuesListApp() { const resolvers = { Mutation: { - reorderIssues: (_, { oldIndex, newIndex, serializedVariables }, { cache }) => { + reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { const variables = JSON.parse(serializedVariables); const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); const data = produce(sourceData, (draftData) => { - const issues = draftData.project.issues.nodes.slice(); + const issues = draftData[namespace].issues.nodes.slice(); const issueToMove = issues[oldIndex]; issues.splice(oldIndex, 1); issues.splice(newIndex, 0, issueToMove); - draftData.project.issues.nodes = issues; + draftData[namespace].issues.nodes = issues; }); cache.writeQuery({ query: getIssuesQuery, variables, data }); @@ -128,8 +128,8 @@ export function mountIssuesListApp() { hasMultipleIssueAssigneesFeature, importCsvIssuesPath, initialEmail, + isProject, isSignedIn, - issuesPath, jiraIntegrationPath, markdownHelpPath, maxAttachmentSize, @@ -158,8 +158,8 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + isProject: parseBoolean(isProject), isSignedIn: parseBoolean(isSignedIn), - issuesPath, jiraIntegrationPath, newIssuePath, rssPath, diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql index 1cb6fef0a12..6df72cf6596 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -2,6 +2,7 @@ #import "./issue.fragment.graphql" query getIssues( + $isProject: Boolean = false $isSignedIn: Boolean = false $fullPath: ID! $search: String @@ -20,7 +21,35 @@ query getIssues( $firstPageSize: Int $lastPageSize: Int ) { - project(fullPath: $fullPath) { + group(fullPath: $fullPath) @skip(if: $isProject) { + issues( + includeSubgroups: true + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + reference(full: true) + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { issues( search: $search sort: $sort diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql index a3765d39ed2..7bcdbbb28fc 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql @@ -1,4 +1,5 @@ query getIssuesCount( + $isProject: Boolean = false $fullPath: ID! $search: String $assigneeId: String @@ -10,7 +11,54 @@ query getIssuesCount( $types: [IssueType!] $not: NegatedIssueFilterInput ) { - project(fullPath: $fullPath) { + group(fullPath: $fullPath) @skip(if: $isProject) { + openedIssues: issues( + includeSubgroups: true + state: opened + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types + not: $not + ) { + count + } + closedIssues: issues( + includeSubgroups: true + state: closed + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types + not: $not + ) { + count + } + allIssues: issues( + includeSubgroups: true + state: all + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types + not: $not + ) { + count + } + } + project(fullPath: $fullPath) @include(if: $isProject) { openedIssues: issues( state: opened search: $search diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql index 633b06eced8..9c46cb3ef64 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -13,6 +13,7 @@ fragment IssueFragment on Issue { updatedAt upvotes userDiscussionsCount @include(if: $isSignedIn) + webPath webUrl assignees { nodes { diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql new file mode 100644 index 00000000000..78a368089a8 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql @@ -0,0 +1,4 @@ +fragment Iteration on Iteration { + id + title +} diff --git a/app/assets/javascripts/issues_list/queries/label.fragment.graphql b/app/assets/javascripts/issues_list/queries/label.fragment.graphql new file mode 100644 index 00000000000..bb1d8f1ac9b --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/label.fragment.graphql @@ -0,0 +1,6 @@ +fragment Label on Label { + id + color + textColor + title +} diff --git a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql b/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql new file mode 100644 index 00000000000..3cdf69bf585 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql @@ -0,0 +1,4 @@ +fragment Milestone on Milestone { + id + title +} diff --git a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql index 5927e3e83c7..160026a4742 100644 --- a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql +++ b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql @@ -1,7 +1,13 @@ -mutation reorderIssues($oldIndex: Int, $newIndex: Int, $serializedVariables: String) { +mutation reorderIssues( + $oldIndex: Int + $newIndex: Int + $namespace: String + $serializedVariables: String +) { reorderIssues( oldIndex: $oldIndex newIndex: $newIndex + namespace: $namespace serializedVariables: $serializedVariables ) @client } diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql index 0bdf3bfda96..93600c62905 100644 --- a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql @@ -1,9 +1,17 @@ -query searchIterations($fullPath: ID!, $search: String, $id: ID) { - project(fullPath: $fullPath) { - iterations(title: $search, id: $id) { +#import "./iteration.fragment.graphql" + +query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + iterations(title: $search, id: $id, includeAncestors: true) { nodes { - id - title + ...Iteration + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + iterations(title: $search, id: $id, includeAncestors: true) { + nodes { + ...Iteration } } } diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql index bdbb0675a24..1515bd91da3 100644 --- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql @@ -1,11 +1,17 @@ -query searchLabels($fullPath: ID!, $search: String) { - project(fullPath: $fullPath) { +#import "./label.fragment.graphql" + +query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { + nodes { + ...Label + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { labels(searchTerm: $search, includeAncestorGroups: true) { nodes { - id - color - textColor - title + ...Label } } } diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql index 93802bd8dd5..8c6c50e9dc2 100644 --- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql @@ -1,9 +1,17 @@ -query searchMilestones($fullPath: ID!, $search: String) { - project(fullPath: $fullPath) { +#import "./milestone.fragment.graphql" + +query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) { + nodes { + ...Milestone + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { milestones(searchTitle: $search, includeAncestors: true) { nodes { - id - title + ...Milestone } } } diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql index 182ab9dd577..0211fc66235 100644 --- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql @@ -1,12 +1,20 @@ -query searchUsers($fullPath: ID!, $search: String) { - project(fullPath: $fullPath) { +#import "./user.fragment.graphql" + +query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + groupMembers(search: $search) { + nodes { + user { + ...User + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { projectMembers(search: $search) { nodes { user { - id - avatarUrl - name - username + ...User } } } diff --git a/app/assets/javascripts/issues_list/queries/user.fragment.graphql b/app/assets/javascripts/issues_list/queries/user.fragment.graphql new file mode 100644 index 00000000000..3e5bc0f7b93 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/user.fragment.graphql @@ -0,0 +1,6 @@ +fragment User on User { + id + avatarUrl + name + username +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index db6834c4084..fd9629499b0 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -117,7 +117,6 @@ export const handleLocationHash = () => { }; // Check if element scrolled into viewport from above or below -// Courtesy http://stackoverflow.com/a/7557433/414749 export const isInViewport = (el, offset = {}) => { const rect = el.getBoundingClientRect(); const { top, left } = offset; @@ -560,8 +559,6 @@ export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => { * Method to round of values with decimal places * with provided precision. * - * Taken from https://stackoverflow.com/a/7343013/414749 - * * Eg; roundOffFloat(3.141592, 3) = 3.142 * * Refer to spec/frontend/lib/utils/common_utils_spec.js for diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index e8499015210..a840e696386 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -125,8 +125,7 @@ export default { // This method is defined here instead of in `methods` // because we need to access the .cancel() method // lodash attaches to the function, which is - // made inaccessible by Vue. More info: - // https://stackoverflow.com/a/52988020/1063392 + // made inaccessible by Vue. this.debouncedSearch = debounce(function search() { this.search(this.searchQuery); }, SEARCH_DEBOUNCE_MS); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 342c054471d..8c9f23732aa 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,26 +1,30 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import { mountIssuablesListApp } from '~/issues_list'; +import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; -const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; +if (gon.features?.vueIssuesList) { + mountIssuesListApp(); +} else { + const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; -IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); -IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); -issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); + issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); -initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - isGroupDecendent: true, - useDefaultState: true, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, -}); -projectSelect(); -initManualOrdering(); + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, + useDefaultState: true, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + }); + projectSelect(); + initManualOrdering(); -if (gon.features?.vueIssuablesList) { - mountIssuablesListApp(); + if (gon.features?.vueIssuablesList) { + mountIssuablesListApp(); + } } diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 82963fe98fd..ce781c64006 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -149,8 +149,7 @@ export default { // This method is defined here instead of in `methods` // because we need to access the .cancel() method // lodash attaches to the function, which is - // made inaccessible by Vue. More info: - // https://stackoverflow.com/a/52988020/1063392 + // made inaccessible by Vue. this.debouncedSearch = debounce(function search() { this.search(); }, SEARCH_DEBOUNCE_MS); diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 3e9e3e6f265..61fe89f4f7e 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -4,7 +4,7 @@ export * from './api/user_api'; export * from './api/markdown_api'; // Note: It's not possible to spy on methods imported from this file in -// Jest tests. See https://stackoverflow.com/a/53307822/1063392. +// Jest tests. // As a workaround, in Jest tests, import the methods from the file // in which they are defined: // diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index a3a2c794a67..8f3c4c644bf 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -19,7 +19,6 @@ const IGNORE_ERRORS = [ 'fb_xd_fragment', // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to // reduce this. (thanks @acdha) - // See http://stackoverflow.com/questions/4113268 'bmi_SafeAddOnload', 'EBCallBackMessageReceived', // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js index d770dd18d7f..e41dc51457a 100644 --- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -18,7 +18,7 @@ Regexp notes: const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; const isIdentifierInstance = (literal) => { - // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + // Reset lastIndex as global flag in regexp are stateful identifierInstanceRegex.lastIndex = 0; return identifierInstanceRegex.test(literal); }; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 726f8e28efe..099dfa28b9f 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -505,7 +505,7 @@ $line-removed-dark: #fac5cd !default; * would hide other layers (selected text, matching brackets). * * When the transparent colors get layered on white background, they create their - * full opacity counterparts (computed with https://stackoverflow.com/a/12228643/606571): + * full opacity counterparts: * * - white + $line-added-transparent = $line-added * - white + $line-added-transparent + $line-added-dark-transparent = $line-added-dark diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index 9d889f111dd..3220510775c 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -1,7 +1,6 @@ @import 'framework/variables'; // Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails -// See https://stackoverflow.com/questions/28551981/why-are-3-digit-hex-color-code-values-interpreted-differently-in-internet-explor // // stylelint-disable color-hex-length diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 6a20ff3b3fa..28354b83856 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -40,7 +40,7 @@ position: -webkit-sticky; position: sticky; - // Unitless zero values are not allowed in calculations https://stackoverflow.com/a/55391061 + // Unitless zero values are not allowed in calculations // stylelint-disable-next-line length-zero-no-unit top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px)); // stylelint-disable-next-line length-zero-no-unit diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8a3c72ae4f8..a419171039e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController before_action do push_frontend_feature_flag(:vue_issuables_list, @group) + push_frontend_feature_flag(:vue_issues_list, @group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d14df4600e1..4c207a46993 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -43,7 +43,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) - push_frontend_feature_flag(:vue_issues_list, project) + push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) end diff --git a/app/graphql/mutations/custom_emoji/destroy.rb b/app/graphql/mutations/custom_emoji/destroy.rb new file mode 100644 index 00000000000..863b8152cc7 --- /dev/null +++ b/app/graphql/mutations/custom_emoji/destroy.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module CustomEmoji + class Destroy < BaseMutation + graphql_name 'DestroyCustomEmoji' + + authorize :delete_custom_emoji + + field :custom_emoji, + Types::CustomEmojiType, + null: true, + description: 'Deleted custom emoji.' + + argument :id, ::Types::GlobalIDType[::CustomEmoji], + required: true, + description: 'Global ID of the custom emoji to destroy.' + + def resolve(id:) + custom_emoji = authorized_find!(id: id) + + custom_emoji.destroy! + + { + custom_emoji: custom_emoji + } + end + + private + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::CustomEmoji) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ac70ff77af6..40d4f86de00 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -33,6 +33,7 @@ module Types mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji + mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji mount_mutation Mutations::CustomerRelations::Organizations::Create mount_mutation Mutations::Discussions::ToggleResolve mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update diff --git a/app/graphql/types/permission_types/custom_emoji.rb b/app/graphql/types/permission_types/custom_emoji.rb index 0b2e7da44f5..61ff85f665d 100644 --- a/app/graphql/types/permission_types/custom_emoji.rb +++ b/app/graphql/types/permission_types/custom_emoji.rb @@ -5,7 +5,7 @@ module Types class CustomEmoji < BasePermissionType graphql_name 'CustomEmojiPermissions' - abilities :create_custom_emoji, :read_custom_emoji + abilities :create_custom_emoji, :read_custom_emoji, :delete_custom_emoji end end end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 18dc882679d..8897d73613c 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -203,34 +203,45 @@ module IssuesHelper } end - def issues_list_data(project, current_user, finder) + def common_issues_list_data(namespace, current_user) { autocomplete_award_emojis_path: autocomplete_award_emojis_path, calendar_path: url_for(safe_params.merge(calendar_url_options)), + empty_state_svg_path: image_path('illustrations/issues.svg'), + full_path: namespace.full_path, + is_signed_in: current_user.present?.to_s, + jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), + rss_path: url_for(safe_params.merge(rss_url_options)), + sign_in_path: new_user_session_path + } + end + + def project_issues_list_data(project, current_user, finder) + common_issues_list_data(project, current_user).merge( can_bulk_update: can?(current_user, :admin_issue, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s, can_import_issues: can?(current_user, :import_issues, @project).to_s, email: current_user&.notification_email, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), - empty_state_svg_path: image_path('illustrations/issues.svg'), export_csv_path: export_csv_project_issues_path(project), - full_path: project.full_path, has_any_issues: project_issues(project).exists?.to_s, import_csv_issues_path: import_csv_namespace_project_issues_path, initial_email: project.new_issuable_address(current_user, 'issue'), - is_signed_in: current_user.present?.to_s, - issues_path: project_issues_path(project), - jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), + is_project: true.to_s, markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }), project_import_jira_path: project_import_jira_path(project), quick_actions_help_path: help_page_path('user/project/quick_actions'), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), - rss_path: url_for(safe_params.merge(rss_url_options)), - show_new_issue_link: show_new_issue_link?(project).to_s, - sign_in_path: new_user_session_path - } + show_new_issue_link: show_new_issue_link?(project).to_s + ) + end + + def group_issues_list_data(group, current_user, issues) + common_issues_list_data(group, current_user).merge( + has_any_issues: issues.to_a.any?.to_s + ) end # Overridden in EE diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 88562458fba..e2e24247679 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -170,8 +170,6 @@ module Ci scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } - scope :with_secure_reports_from_options, -> (job_type) { where('options like :job_type', job_type: "%:artifacts:%:reports:%:#{job_type}:%") } - scope :with_secure_reports_from_config_options, -> (job_types) do joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end diff --git a/app/models/issue.rb b/app/models/issue.rb index 724b9d08fdf..dd7a543e026 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -323,6 +323,13 @@ class Issue < ApplicationRecord ) end + def self.column_order_id_asc + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].asc + ) + end + def self.to_branch_name(*args) branch_name = args.map(&:to_s).each_with_index.map do |arg, i| arg.parameterize(preserve_case: i == 0).presence diff --git a/app/models/project.rb b/app/models/project.rb index 2d585ca5a0e..1d0fa699a12 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -526,6 +526,7 @@ class Project < ApplicationRecord scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) } # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name + scope :projects_order_id_asc, -> { reorder(self.arel_table['id'].asc) } scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } scope :sorted_by_similarity_desc, -> (search, include_in_select: false) do diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb index ba73b9a3782..98d1ab737ee 100644 --- a/app/policies/custom_emoji_policy.rb +++ b/app/policies/custom_emoji_policy.rb @@ -2,4 +2,14 @@ class CustomEmojiPolicy < BasePolicy delegate { @subject.group } + + condition(:author) { @subject.creator == @user } + + rule { can?(:maintainer_access) }.policy do + enable :delete_custom_emoji + end + + rule { author & can?(:create_custom_emoji) }.policy do + enable :delete_custom_emoji + end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 018ce4fb72a..7abffd2c352 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -89,6 +89,7 @@ class GroupPolicy < BasePolicy rule { guest }.policy do enable :read_group enable :upload_file + enable :guest_access end rule { admin }.policy do @@ -132,6 +133,7 @@ class GroupPolicy < BasePolicy enable :create_custom_emoji enable :create_package enable :create_package_settings + enable :developer_access end rule { reporter }.policy do @@ -161,6 +163,7 @@ class GroupPolicy < BasePolicy enable :read_deploy_token enable :create_jira_connect_subscription enable :update_runners_registration_token + enable :maintainer_access end rule { owner }.policy do @@ -176,6 +179,7 @@ class GroupPolicy < BasePolicy enable :update_default_branch_protection enable :create_deploy_token enable :destroy_deploy_token + enable :owner_access end rule { can?(:read_nested_project_resources) }.policy do diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 611294ddfd8..f56760b55df 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -34,7 +34,6 @@ module Projects # We need `.connected_to_user` here otherwise when a group has an # invitee, it would make the following query return 0 rows since a NULL # user_id would be present in the subquery - # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values non_null_user_ids = project.project_members.connected_to_user.select(:user_id) GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids) end diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb deleted file mode 100644 index 142d287370f..00000000000 --- a/app/services/issue_rebalancing_service.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -class IssueRebalancingService - MAX_ISSUE_COUNT = 10_000 - BATCH_SIZE = 100 - SMALLEST_BATCH_SIZE = 5 - RETRIES_LIMIT = 3 - TooManyIssues = Class.new(StandardError) - - TIMING_CONFIGURATION = [ - [0.1.seconds, 0.05.seconds], # short timings, lock_timeout: 100ms, sleep after LockWaitTimeout: 50ms - [0.5.seconds, 0.05.seconds], - [1.second, 0.5.seconds], - [1.second, 0.5.seconds], - [5.seconds, 1.second] - ].freeze - - def initialize(projects_collection) - @root_namespace = projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord - @base = Issue.in_projects(projects_collection) - end - - def execute - return unless Feature.enabled?(:rebalance_issues, root_namespace) - - raise TooManyIssues, "#{issue_count} issues" if issue_count > MAX_ISSUE_COUNT - - start = RelativePositioning::START_POSITION - (gaps / 2) * gap_size - - if Feature.enabled?(:issue_rebalancing_optimization) - Issue.transaction do - assign_positions(start, indexed_ids) - .sort_by(&:first) - .each_slice(BATCH_SIZE) do |pairs_with_position| - if Feature.enabled?(:issue_rebalancing_with_retry) - update_positions_with_retry(pairs_with_position, 'rebalance issue positions in batches ordered by id') - else - update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id') - end - end - end - else - Issue.transaction do - indexed_ids.each_slice(BATCH_SIZE) do |pairs| - pairs_with_position = assign_positions(start, pairs) - - if Feature.enabled?(:issue_rebalancing_with_retry) - update_positions_with_retry(pairs_with_position, 'rebalance issue positions') - else - update_positions(pairs_with_position, 'rebalance issue positions') - end - end - end - end - end - - private - - attr_reader :root_namespace, :base - - # rubocop: disable CodeReuse/ActiveRecord - def indexed_ids - base.reorder(:relative_position, :id).pluck(:id).each_with_index - end - # rubocop: enable CodeReuse/ActiveRecord - - def assign_positions(start, pairs) - pairs.map do |id, index| - [id, start + (index * gap_size)] - end - end - - def update_positions_with_retry(pairs_with_position, query_name) - retries = 0 - batch_size = pairs_with_position.size - - until pairs_with_position.empty? - begin - update_positions(pairs_with_position.first(batch_size), query_name) - pairs_with_position = pairs_with_position.drop(batch_size) - retries = 0 - rescue ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled => ex - raise ex if batch_size < SMALLEST_BATCH_SIZE - - if (retries += 1) == RETRIES_LIMIT - # shrink the batch size in half when RETRIES limit is reached and update still fails perhaps because batch size is still too big - batch_size = (batch_size / 2).to_i - retries = 0 - end - - retry - end - end - end - - def update_positions(pairs_with_position, query_name) - values = pairs_with_position.map do |id, index| - "(#{id}, #{index})" - end.join(', ') - - Gitlab::Database::WithLockRetries.new(timing_configuration: TIMING_CONFIGURATION, klass: self.class).run do - run_update_query(values, query_name) - end - end - - def run_update_query(values, query_name) - Issue.connection.exec_query(<<~SQL, query_name) - WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( - SELECT * - FROM (VALUES #{values}) as t (id, pos) - ) - UPDATE #{Issue.table_name} - SET relative_position = cte.new_pos - FROM cte - WHERE cte_id = id - SQL - end - - def issue_count - @issue_count ||= base.count - end - - def gaps - issue_count - 1 - end - - def gap_size - # We could try to split the available range over the number of gaps we need, - # but IDEAL_DISTANCE * MAX_ISSUE_COUNT is only 0.1% of the available range, - # so we are guaranteed not to exhaust it by using this static value. - # - # If we raise MAX_ISSUE_COUNT or IDEAL_DISTANCE significantly, this may - # change! - RelativePositioning::IDEAL_DISTANCE - end -end diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb new file mode 100644 index 00000000000..7d199f99a24 --- /dev/null +++ b/app/services/issues/relative_position_rebalancing_service.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Issues + class RelativePositionRebalancingService + UPDATE_BATCH_SIZE = 100 + PREFETCH_ISSUES_BATCH_SIZE = 10_000 + SMALLEST_BATCH_SIZE = 5 + RETRIES_LIMIT = 3 + + TooManyConcurrentRebalances = Class.new(StandardError) + + def initialize(projects) + @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).projects_order_id_asc + @root_namespace = @projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord + @caching = ::Gitlab::Issues::Rebalancing::State.new(@root_namespace, @projects_collection) + end + + def execute + return unless Feature.enabled?(:rebalance_issues, root_namespace) + + # Given can_start_rebalance? and track_new_running_rebalance are not atomic + # it can happen that we end up with more than Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES running. + # Considering the number of allowed Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES is small we should be ok, + # but should be something to consider if we'd want to scale this up. + error_message = "#{caching.concurrent_running_rebalances_count} concurrent re-balances currently running" + raise TooManyConcurrentRebalances, error_message unless caching.can_start_rebalance? + + block_issue_repositioning! unless root_namespace.issue_repositioning_disabled? + caching.track_new_running_rebalance + index = caching.get_current_index + + loop do + issue_ids = get_issue_ids(index, PREFETCH_ISSUES_BATCH_SIZE) + pairs_with_index = assign_indexes(issue_ids, index) + + pairs_with_index.each_slice(UPDATE_BATCH_SIZE) do |pairs_batch| + update_positions_with_retry(pairs_batch, 're-balance issue positions in batches ordered by position') + end + + index = caching.get_current_index + + break if index >= caching.issue_count - 1 + end + + caching.cleanup_cache + unblock_issue_repositioning! + end + + private + + attr_reader :root_namespace, :projects_collection, :caching + + def block_issue_repositioning! + Feature.enable(:block_issue_repositioning, root_namespace) + end + + def unblock_issue_repositioning! + Feature.disable(:block_issue_repositioning, root_namespace) + end + + def get_issue_ids(index, limit) + issue_ids = caching.get_cached_issue_ids(index, limit) + + # if we have a list of cached issues and no current project id cached, + # then we successfully cached issues for all projects + return issue_ids if issue_ids.any? && caching.get_current_project_id.blank? + + # if we got no issue ids at the start of re-balancing then we did not cache any issue ids yet + preload_issue_ids + + caching.get_cached_issue_ids(index, limit) + end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_issue_ids + index = 0 + cached_project_id = caching.get_current_project_id + + collection = projects_collection + collection = projects_collection.where(Project.arel_table[:id].gteq(cached_project_id.to_i)) if cached_project_id.present? + + collection.each do |project| + caching.cache_current_project_id(project.id) + index += 1 + scope = Issue.in_projects(project).reorder(custom_reorder).select(:id, :relative_position) + + with_retry(PREFETCH_ISSUES_BATCH_SIZE, 100) do |batch_size| + Gitlab::Pagination::Keyset::Iterator.new(scope: scope).each_batch(of: batch_size) do |batch| + caching.cache_issue_ids(batch) + end + end + end + + caching.remove_current_project_id_cache + end + # rubocop: enable CodeReuse/ActiveRecord + + def assign_indexes(ids, start_index) + ids.each_with_index.map do |id, idx| + [id, start_index + idx] + end + end + + # The method runs in a loop where we try for RETRIES_LIMIT=3 times, to run the update statement on + # a number of records(batch size). Method gets an array of (id, value) pairs as argument that is used + # to build the update query matching by id and updating relative_position = value. If we get a statement + # timeout, we split the batch size in half and try(for 3 times again) to batch update on a smaller number of records. + # On success, because we know the batch size and we always pick from the beginning of the array param, + # we can remove first batch_size number of items from array and continue with the successful batch_size for next batches. + # On failures we continue to split batch size to a SMALLEST_BATCH_SIZE limit, which is now set at 5. + # + # e.g. + # 0. items | previous batch size|new batch size | comment + # 1. 100 | 100 | 100 | 3 failures -> split the batch size in half + # 2. 100 | 100 | 50 | 3 failures -> split the batch size in half again + # 3. 100 | 50 | 25 | 3 succeed -> so we drop 25 items 3 times, 4th fails -> split the batch size in half again + # 5. 25 | 25 | 12 | 3 failures -> split the batch size in half + # 6. 25 | 12 | 6 | 3 failures -> we exit because smallest batch size is 5 and we'll be at 3 if we split again + + def update_positions_with_retry(pairs_with_index, query_name) + retry_batch_size = pairs_with_index.size + + until pairs_with_index.empty? + with_retry(retry_batch_size, SMALLEST_BATCH_SIZE) do |batch_size| + retry_batch_size = batch_size + update_positions(pairs_with_index.first(batch_size), query_name) + # pairs_with_index[batch_size - 1] - can be nil for last batch + # if last batch is smaller than batch_size, so we just get the last pair. + last_pair_in_batch = pairs_with_index[batch_size - 1] || pairs_with_index.last + caching.cache_current_index(last_pair_in_batch.last + 1) + pairs_with_index = pairs_with_index.drop(batch_size) + end + end + end + + def update_positions(pairs_with_position, query_name) + values = pairs_with_position.map do |id, index| + "(#{id}, #{start_position + (index * gap_size)})" + end.join(', ') + + run_update_query(values, query_name) + end + + def run_update_query(values, query_name) + Issue.connection.exec_query(<<~SQL, query_name) + WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( + SELECT * + FROM (VALUES #{values}) as t (id, pos) + ) + UPDATE #{Issue.table_name} + SET relative_position = cte.new_pos + FROM cte + WHERE cte_id = id + SQL + end + + def gaps + caching.issue_count - 1 + end + + def gap_size + RelativePositioning::MAX_GAP + end + + def start_position + @start_position ||= (RelativePositioning::START_POSITION - (gaps / 2) * gap_size).to_i + end + + def custom_reorder + ::Gitlab::Pagination::Keyset::Order.build([Issue.column_order_relative_position, Issue.column_order_id_asc]) + end + + def with_retry(initial_batch_size, exit_batch_size) + retries = 0 + batch_size = initial_batch_size + + begin + yield batch_size + retries = 0 + rescue ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled => ex + raise ex if batch_size < exit_batch_size + + if (retries += 1) == RETRIES_LIMIT + # shrink the batch size in half when RETRIES limit is reached and update still fails perhaps because batch size is still too big + batch_size = (batch_size / 2).to_i + retries = 0 + end + + retry + end + end + end +end diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index fdd6962eb21..1f746484b7d 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -5,29 +5,34 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") -.top-area - = render 'shared/issuable/nav', type: :issues - .nav-controls - = render 'shared/issuable/feed_buttons' - - - if @can_bulk_update - = render_if_exists 'shared/issuable/bulk_update_button', type: :issues - - = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true - -= render 'shared/issuable/search_bar', type: :issues - -- if @can_bulk_update - = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - -- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any? - - if use_startup_call? - - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params)) - .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), - 'can-bulk-edit': @can_bulk_update.to_json, - 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, - 'sort-key': @sort, - type: 'issues', - 'scoped-labels-available': scoped_labels_available?(@group).to_json } } +- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml) + .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) } + - if @can_bulk_update + = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - else - = render 'shared/issues', project_select_button: true + .top-area + = render 'shared/issuable/nav', type: :issues + .nav-controls + = render 'shared/issuable/feed_buttons' + + - if @can_bulk_update + = render_if_exists 'shared/issuable/bulk_update_button', type: :issues + + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true + + = render 'shared/issuable/search_bar', type: :issues + + - if @can_bulk_update + = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues + + - if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any? + - if use_startup_call? + - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params)) + .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), + 'can-bulk-edit': @can_bulk_update.to_json, + 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, + 'sort-key': @sort, + type: 'issues', + 'scoped-labels-available': scoped_labels_available?(@group).to_json } } + - else + = render 'shared/issues', project_select_button: true diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index ecf10cd4821..53c2052bfab 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -13,8 +13,8 @@ issues_path: project_issues_path(@project), project_path: @project.full_path } } -- if Feature.enabled?(:vue_issues_list, @project) - .js-issues-list{ data: issues_list_data(@project, current_user, finder) } +- if Feature.enabled?(:vue_issues_list, @project&.group, default_enabled: :yaml) + .js-issues-list{ data: project_issues_list_data(@project, current_user, finder) } - if @can_bulk_update = render 'shared/issuable/bulk_update_sidebar', type: :issues - elsif project_issues(@project).exists? diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index a7676c312f9..01984197aae 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -32,12 +32,8 @@ class IssueRebalancingWorker return end - # Temporary disable rebalancing for performance reasons - # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 - return if projects_to_rebalance.take&.root_namespace&.issue_repositioning_disabled? # rubocop:disable CodeReuse/ActiveRecord - - IssueRebalancingService.new(projects_to_rebalance).execute - rescue IssueRebalancingService::TooManyIssues => e + Issues::RelativePositionRebalancingService.new(projects_to_rebalance).execute + rescue Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances => e Gitlab::ErrorTracking.log_exception(e, root_namespace_id: root_namespace_id, project_id: project_id) end diff --git a/config/feature_flags/development/issue_rebalancing_optimization.yml b/config/feature_flags/development/issue_rebalancing_optimization.yml deleted file mode 100644 index abaeb53f63d..00000000000 --- a/config/feature_flags/development/issue_rebalancing_optimization.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: issue_rebalancing_optimization -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53384 -rollout_issue_url: -milestone: '13.9' -type: development -group: group::project management -default_enabled: false diff --git a/config/feature_flags/development/issue_rebalancing_with_retry.yml b/config/feature_flags/development/issue_rebalancing_with_retry.yml deleted file mode 100644 index c30d919d592..00000000000 --- a/config/feature_flags/development/issue_rebalancing_with_retry.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: issue_rebalancing_with_retry -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59744 -rollout_issue_url: -milestone: '13.11' -type: development -group: group::project management -default_enabled: false diff --git a/config/feature_flags/development/roadmap_daterange_filter.yml b/config/feature_flags/development/roadmap_daterange_filter.yml deleted file mode 100644 index 276242427c2..00000000000 --- a/config/feature_flags/development/roadmap_daterange_filter.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: roadmap_daterange_filter -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55639 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323917 -milestone: '14.3' -type: development -group: group::product planning -default_enabled: false diff --git a/config/initializers/0_marginalia.rb b/config/initializers/0_marginalia.rb index 7e48c9d4fcd..f7a1f5f0469 100644 --- a/config/initializers/0_marginalia.rb +++ b/config/initializers/0_marginalia.rb @@ -13,7 +13,7 @@ require 'marginalia' # matching against the raw SQL, and prepending the comment prevents color # coding from working in the development log. Marginalia::Comment.prepend_comment = true if Rails.env.production? -Marginalia::Comment.components = [:application, :correlation_id, :jid, :endpoint_id] +Marginalia::Comment.components = [:application, :correlation_id, :jid, :endpoint_id, :db_config_name] # As mentioned in https://github.com/basecamp/marginalia/pull/93/files, # adding :line has some overhead because a regexp on the backtrace has diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml index 2989b3a4262..3dc4b10a6a8 100644 --- a/config/known_invalid_graphql_queries.yml +++ b/config/known_invalid_graphql_queries.yml @@ -3,4 +3,3 @@ filenames: - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql - ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/api_fuzzing_ci_configuration.query.graphql - ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql - - ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 2c2031ce2ae..78bd030f6ea 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -17,9 +17,8 @@ # 3: high priority # 5: _super_ high priority, this should only be used for _very_ important queues # -# As per http://stackoverflow.com/a/21241357/290102 the formula for calculating -# the likelihood of a job being popped off a queue (given all queues have work -# to perform) is: +# The formula for calculating the likelihood of a job being popped off a queue +# (given all queues have work to perform) is: # # chance = (queue weight / total weight of all queues) * 100 --- diff --git a/db/migrate/20201029144444_create_vulnerability_finding_links.rb b/db/migrate/20201029144444_create_vulnerability_finding_links.rb index 80f93b9a0af..225a2de6e19 100644 --- a/db/migrate/20201029144444_create_vulnerability_finding_links.rb +++ b/db/migrate/20201029144444_create_vulnerability_finding_links.rb @@ -11,8 +11,8 @@ class CreateVulnerabilityFindingLinks < ActiveRecord::Migration[6.0] create_table :vulnerability_finding_links, if_not_exists: true do |t| t.timestamps_with_timezone null: false t.references :vulnerability_occurrence, index: { name: 'finding_links_on_vulnerability_occurrence_id' }, null: false, foreign_key: { on_delete: :cascade } - t.text :name, limit: 255 - t.text :url, limit: 2048, null: false + t.text :name + t.text :url, null: false end add_text_limit :vulnerability_finding_links, :name, 255 diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md index a3ce605e9b8..491db37d9da 100644 --- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md +++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md @@ -418,7 +418,7 @@ p.create_wiki ### creates the wiki project on the filesystem ```ruby p = Project.find_by_full_path('PROJECT PATH') -IssueRebalancingService.new(p.issues.take).execute +Issues::RelativePositionRebalancingService.new(p.root_namespace.all_projects).execute ``` ## Imports / Exports diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 6a24eae6c35..a5e6a5ef831 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1924,6 +1924,27 @@ Input type: `DestroyContainerRepositoryTagsInput` | `deletedTagNames` | [`[String!]!`](#string) | Deleted container repository tags. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.destroyCustomEmoji` + +Available only when feature flag `custom_emoji` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice. + +Input type: `DestroyCustomEmojiInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `id` | [`CustomEmojiID!`](#customemojiid) | Global ID of the custom emoji to destroy. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `customEmoji` | [`CustomEmoji`](#customemoji) | Deleted custom emoji. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.destroyEpicBoard` Input type: `DestroyEpicBoardInput` @@ -12526,6 +12547,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | | `normalizedTargetUrls` | [`[String!]`](#string) | Normalized URL of the target to be scanned. | +| `status` | [`DastSiteValidationStatusEnum`](#dastsitevalidationstatusenum) | Status of the site validation. Ignored if `dast_failed_site_validations` feature flag is disabled. | ##### `Project.environment` @@ -15376,6 +15398,15 @@ Unit for the duration of Dast Profile Cadence. | `PASSED_VALIDATION` | Site validation process finished successfully. | | `PENDING_VALIDATION` | Site validation process has not started. | +### `DastSiteValidationStatusEnum` + +| Value | Description | +| ----- | ----------- | +| `FAILED_VALIDATION` | Site validation process finished but failed. | +| `INPROGRESS_VALIDATION` | Site validation process is in progress. | +| `PASSED_VALIDATION` | Site validation process finished successfully. | +| `PENDING_VALIDATION` | Site validation process has not started. | + ### `DastSiteValidationStrategyEnum` | Value | Description | diff --git a/doc/development/database/strings_and_the_text_data_type.md b/doc/development/database/strings_and_the_text_data_type.md index 92d70c9cba5..a0dda42fdc7 100644 --- a/doc/development/database/strings_and_the_text_data_type.md +++ b/doc/development/database/strings_and_the_text_data_type.md @@ -11,11 +11,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w When adding new columns that will be used to store strings or other textual information: 1. We always use the `text` data type instead of the `string` data type. -1. `text` columns should always have a limit set, either by using the `create_table_with_constraints` helper -when creating a table, or by using the `add_text_limit` when altering an existing table. +1. `text` columns should always have a limit set, either by using the `create_table` with +the `#text ... limit: 100` helper (see below) when creating a table, or by using the `add_text_limit` +when altering an existing table. -The `text` data type can not be defined with a limit, so `create_table_with_constraints` and `add_text_limit` enforce -that by adding a [check constraint](https://www.postgresql.org/docs/11/ddl-constraints.html) on the column. +The standard Rails `text` column type can not be defined with a limit, but we extend `create_table` to +add a `limit: 255` option. Outside of `create_table`, `add_text_limit` can be used to add a [check constraint](https://www.postgresql.org/docs/11/ddl-constraints.html) +to an already existing column. ## Background information @@ -41,34 +43,24 @@ Don't use text columns for `attr_encrypted` attributes. Use a ## Create a new table with text columns When adding a new table, the limits for all text columns should be added in the same migration as -the table creation. +the table creation. We add a `limit:` attribute to Rails' `#text` method, which allows adding a limit +for this column. For example, consider a migration that creates a table with two text columns, `db/migrate/20200401000001_create_db_guides.rb`: ```ruby class CreateDbGuides < Gitlab::Database::Migration[1.0] - def up - create_table_with_constraints :db_guides do |t| + def change + create_table :db_guides do |t| t.bigint :stars, default: 0, null: false - t.text :title - t.text :notes - - t.text_limit :title, 128 - t.text_limit :notes, 1024 + t.text :title, limit: 128 + t.text :notes, limit: 1024 end end - - def down - # No need to drop the constraints, drop_table takes care of everything - drop_table :db_guides - end end ``` -Note that the `create_table_with_constraints` helper uses the `with_lock_retries` helper -internally, so we don't need to manually wrap the method call in the migration. - ## Add a text column to an existing table Adding a column to an existing table requires an exclusive lock for that table. Even though that lock diff --git a/doc/development/database_review.md b/doc/development/database_review.md index 4698478d309..42bfa656a61 100644 --- a/doc/development/database_review.md +++ b/doc/development/database_review.md @@ -108,7 +108,7 @@ the following preparations into account. - Ensure the down method reverts the changes in `db/structure.sql`. - Update the migration output whenever you modify the migrations during the review process. - Add tests for the migration in `spec/migrations` if necessary. See [Testing Rails migrations at GitLab](testing_guide/testing_migrations_guide.md) for more details. -- When [high-traffic](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/rubocop-migrations.yml#L3) tables are involved in the migration, use the [`with_lock_retries`](migration_style_guide.md#retry-mechanism-when-acquiring-database-locks) helper method. Review the relevant [examples in our documentation](migration_style_guide.md#examples) for use cases and solutions. +- When [high-traffic](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/rubocop-migrations.yml#L3) tables are involved in the migration, use the [`enable_lock_retries`](migration_style_guide.md#retry-mechanism-when-acquiring-database-locks) method to enable lock-retries. Review the relevant [examples in our documentation](migration_style_guide.md#usage-with-transactional-migrations) for use cases and solutions. - Ensure RuboCop checks are not disabled unless there's a valid reason to. - When adding an index to a [large table](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/rubocop-migrations.yml#L3), test its execution using `CREATE INDEX CONCURRENTLY` in the `#database-lab` Slack channel and add the execution time to the MR description: diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index c0ea4bda003..454eb2d0fc4 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -281,79 +281,91 @@ This problem could cause failed application upgrade processes and even applicati stability issues, since the table may be inaccessible for a short period of time. To increase the reliability and stability of database migrations, the GitLab codebase -offers a helper method to retry the operations with different `lock_timeout` settings -and wait time between the attempts. Multiple smaller attempts to acquire the necessary +offers a method to retry the operations with different `lock_timeout` settings +and wait time between the attempts. Multiple shorter attempts to acquire the necessary lock allow the database to process other statements. -### Examples +There are two distinct ways to use lock retries: + +1. Inside a transactional migration: use `enable_lock_retries!`. +1. Inside a non-transactional migration: use `with_lock_retries`. + +If possible, enable lock-retries for any migration that touches a [high-traffic table](#high-traffic-tables). + +### Usage with transactional migrations + +Regular migrations execute the full migration in a transaction. We can enable the +lock-retry methodology by calling `enable_lock_retries!` at the migration level. + +This leads to the lock timeout being controlled for this migration. Also, it can lead to retrying the full +migration if the lock could not be granted within the timeout. + +Note that, while this is currently an opt-in setting, we prefer to use lock-retries for all migrations and +plan to make this the default going forward. + +Occasionally a migration may need to acquire multiple locks on different objects. +To prevent catalog bloat, ask for all those locks explicitly before performing any DDL. +A better strategy is to split the migration, so that we only need to acquire one lock at the time. **Removing a column:** ```ruby +enable_lock_retries! + def up - with_lock_retries do - remove_column :users, :full_name - end + remove_column :users, :full_name end def down - with_lock_retries do - add_column :users, :full_name, :string - end + add_column :users, :full_name, :string end ``` **Multiple changes on the same table:** -The helper `with_lock_retries` wraps all operations into a single transaction. When you have the lock, +With the lock-retry methodology enabled, all operations wrap into a single transaction. When you have the lock, you should do as much as possible inside the transaction rather than trying to get another lock later. Be careful about running long database statements within the block. The acquired locks are kept until the transaction (block) finishes and depending on the lock type, it might block other database operations. ```ruby +enable_lock_retries! + def up - with_lock_retries do - add_column :users, :full_name, :string - add_column :users, :bio, :string - end + add_column :users, :full_name, :string + add_column :users, :bio, :string end def down - with_lock_retries do - remove_column :users, :full_name - remove_column :users, :bio - end + remove_column :users, :full_name + remove_column :users, :bio end ``` **Removing a foreign key:** ```ruby +enable_lock_retries! + def up - with_lock_retries do - remove_foreign_key :issues, :projects - end + remove_foreign_key :issues, :projects end def down - with_lock_retries do - add_foreign_key :issues, :projects - end + add_foreign_key :issues, :projects end ``` **Changing default value for a column:** ```ruby +enable_lock_retries! + def up - with_lock_retries do - change_column_default :merge_requests, :lock_version, from: nil, to: 0 - end + change_column_default :merge_requests, :lock_version, from: nil, to: 0 end def down - with_lock_retries do - change_column_default :merge_requests, :lock_version, from: 0, to: nil - end + change_column_default :merge_requests, :lock_version, from: 0, to: nil end ``` @@ -362,19 +374,17 @@ end We can wrap the `create_table` method with `with_lock_retries`: ```ruby +enable_lock_retries! + def up - with_lock_retries do - create_table :issues do |t| - t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade } - t.string :title, limit: 255 - end + create_table :issues do |t| + t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade } + t.string :title, limit: 255 end end def down - with_lock_retries do - drop_table :issues - end + drop_table :issues end ``` @@ -442,16 +452,20 @@ def down end ``` -**Usage with `disable_ddl_transaction!`** +### Usage with non-transactional migrations (`disable_ddl_transaction!`) -Generally the `with_lock_retries` helper should work with `disable_ddl_transaction!`. A custom RuboCop rule ensures that only allowed methods can be placed within the lock retries block. +Only when we disable transactional migrations using `disable_ddl_transaction!`, we can use +the `with_lock_retries` helper to guard an individual sequence of steps. It opens a transaction +to execute the given block. + +A custom RuboCop rule ensures that only allowed methods can be placed within the lock retries block. ```ruby disable_ddl_transaction! def up with_lock_retries do - add_column :users, :name, :text + add_column :users, :name, :text unless column_exists?(:users, :name) end add_text_limit :users, :name, 255 # Includes constraint validation (full table scan) @@ -472,7 +486,8 @@ end ### When to use the helper method -The `with_lock_retries` helper method can be used when you normally use +You can **only** use the `with_lock_retries` helper method when the execution is not already inside +an open transaction (using Postgres subtransactions is discouraged). It can be used with standard Rails migration helper methods. Calling more than one migration helper is not a problem if they're executed on the same table. diff --git a/doc/subscriptions/quarterly_reconciliation.md b/doc/subscriptions/quarterly_reconciliation.md index af9fec1ddc4..f9cca079e76 100644 --- a/doc/subscriptions/quarterly_reconciliation.md +++ b/doc/subscriptions/quarterly_reconciliation.md @@ -18,3 +18,31 @@ pay 25% of what you would have paid previously. This results in substantial savi If it's not possible for you to participate in quarterly reconciliations, you can opt out of the process by using a contract amendment. In that case, you default to the annual review. + +## Timeline for invoicing and payment + +At the end of each subscription quarter, GitLab notifies you about overages. +The date you're notified about the overage is not the same as the date +you are billed. + +### GitLab SaaS + +Group owners receive an email **on the reconciliation date**. +The email communicates the [overage seat quantity](gitlab_com/index.md#seats-owed-example) +and expected invoice amount. + +**Seven days later**, the subscription is updated to include the additional +seats, and an invoice is generated for a prorated amount. If a credit card +is on file, a payment is automatically applied. Otherwise, an invoice is +sent and subject to your terms. + +### Self-managed instances + +Admins receive an email **six days after the reconciliation date**. +This email communicates the [overage seat quantity](self_managed/index.md#users-over-license) +and expected invoice amount. + +**Seven days later**, the subscription is updated to include the additional +seats, and an invoice is generated for a prorated amount. If a credit card +is on file, a payment is automatically applied. Otherwise, an invoice is +sent and subject to your payment terms. diff --git a/doc/user/analytics/productivity_analytics.md b/doc/user/analytics/productivity_analytics.md index a06d94caf69..391ec5c04d9 100644 --- a/doc/user/analytics/productivity_analytics.md +++ b/doc/user/analytics/productivity_analytics.md @@ -50,11 +50,11 @@ The following metrics and visualizations are available on a project or group lev > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13188) in GitLab 12.4. -GitLab has the ability to filter analytics based on a date range. To filter results: +You can filter analytics based on a date range. To filter results: 1. Select a group. -1. Optionally select a project. -1. Select a date range using the available date pickers. +1. Optional. Select a project. +1. Select a date range by using the available date pickers. ## Permissions diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md index d7a1d3b2b8f..ad9b538ef51 100644 --- a/doc/user/clusters/agent/index.md +++ b/doc/user/clusters/agent/index.md @@ -176,17 +176,14 @@ To perform a one-liner installation, run the command below. Make sure to replace - `your-agent-token` with the token received from the previous step (identified as `secret` in the JSON output). - `gitlab-kubernetes-agent` with the namespace you defined in the previous step. - `wss://kas.gitlab.example.com` with the configured access of the Kubernetes Agent Server (KAS). For GitLab.com users, the KAS is available under `wss://kas.gitlab.com`. +- `--agent-version=vX.Y.Z` with the latest released patch version matching your GitLab installation's major and minor versions. For example, for GitLab v13.9.0, use `--agent-version=v13.9.1`. You can find your GitLab version under the "Help/Help" menu. ```shell -docker run --pull=always --rm registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate --agent-token=your-agent-token --kas-address=wss://kas.gitlab.example.com --agent-version stable --namespace gitlab-kubernetes-agent | kubectl apply -f - +docker run --pull=always --rm registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate --agent-token=your-agent-token --kas-address=wss://kas.gitlab.example.com --agent-version=vX.Y.Z --namespace gitlab-kubernetes-agent | kubectl apply -f - ``` -Set `--agent-version` to the latest released patch version matching your -GitLab installation's major and minor versions. For example, if you have -GitLab v13.9.0, set `--agent-version=v13.9.1`. - WARNING: -Version `stable` can be used to refer to the latest stable release at the time when the command runs. It's fine for +`--agent-version stable` can be used to refer to the latest stable release at the time when the command runs. It's fine for testing purposes but for production please make sure to specify a matching version explicitly. To find out the various options the above Docker container supports, run: @@ -289,7 +286,7 @@ spec: containers: - name: agent # Make sure to specify a matching version for production - image: "registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/agentk:stable" + image: "registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/agentk:vX.Y.Z args: - --token-file=/config/token - --kas-address @@ -385,29 +382,16 @@ Each time you push a change to a monitored manifest repository, the Agent logs t #### Example manifest file -This file creates an NGINX deployment. +This file creates a minimal `ConfigMap`: ```yaml -apiVersion: apps/v1 -kind: Deployment +apiVersion: v1 +kind: ConfigMap metadata: - name: nginx-deployment + name: demo-map namespace: gitlab-kubernetes-agent # Can be any namespace managed by you that the agent has access to. -spec: - selector: - matchLabels: - app: nginx - replicas: 2 - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - ports: - - containerPort: 80 +data: + key: value ``` ## Example projects diff --git a/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png b/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png deleted file mode 100644 index 5fd1bac5e0502fd32bd645bc9c2c1aaff9cd2c58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37271 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 AmjD0& diff --git a/doc/user/clusters/management_project.md b/doc/user/clusters/management_project.md index 2d8b69a306a..ca6843f6fde 100644 --- a/doc/user/clusters/management_project.md +++ b/doc/user/clusters/management_project.md @@ -32,17 +32,20 @@ Management projects are restricted to the following: group (or descendants) as the cluster's group. - For instance-level clusters, there are no such restrictions. -## Usage +## How to create and configure a cluster management project -To use a cluster management project for a cluster: +To use a cluster management project to manage your cluster: -1. Select the project. -1. Configure your pipelines. -1. Set an environment scope. +1. Create a new project to serve as the cluster management project +for your cluster. We recommend that you +[create this project based on the Cluster Management project template](management_project_template.md#create-a-new-project-based-on-the-cluster-management-template). +1. [Associate the cluster with the management project](#associate-the-cluster-management-project-with-the-cluster). +1. [Configure your cluster's pipelines](#configuring-your-pipeline). +1. [Set the environment scope](#setting-the-environment-scope). -### Selecting a cluster management project +### Associate the cluster management project with the cluster -To select a cluster management project to use: +To associate a cluster management project with your cluster: 1. Navigate to the appropriate configuration page. For a: - [Project-level cluster](../project/clusters/index.md), go to your project's @@ -50,10 +53,9 @@ To select a cluster management project to use: - [Group-level cluster](../group/clusters/index.md), go to your group's **Kubernetes** page. - [Instance-level cluster](../instance/clusters/index.md), on the top bar, select **Menu > Admin > Kubernetes**. -1. Select the project using **Cluster management project field** in the **Advanced settings** - section. - -![Selecting a cluster management project under Advanced settings](img/advanced-settings-cluster-management-project-v12_5.png) +1. Expand **Advanced settings**. +1. From the **Cluster management project** dropdown, select the cluster management project +you created in the previous step. ### Configuring your pipeline diff --git a/doc/user/clusters/management_project_template.md b/doc/user/clusters/management_project_template.md index e10d45264f9..9e2b00a0f54 100644 --- a/doc/user/clusters/management_project_template.md +++ b/doc/user/clusters/management_project_template.md @@ -4,39 +4,68 @@ group: Configure info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Cluster Management Project Template **(FREE)** +# Cluster Management project template **(FREE)** > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25318) in GitLab 12.10 with Helmfile support via Helm v2. -> - [Improved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63577) in GitLab 14.0 with Helmfile support via Helm v3 instead, and a much more flexible usage of Helmfile. This introduces breaking changes that are detailed below. +> - Helm v2 support was [dropped](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63577) in GitLab 14.0. Use Helm v3 instead. -This [GitLab built-in project template](../project/working_with_projects.md#built-in-templates) -provides a quicker start for users interested in managing cluster -applications via [Helm v3](https://helm.sh/) charts. More specifically, taking advantage of the -[Helmfile](https://github.com/roboll/helmfile) utility client. The template consists of some pre-configured apps that -should help you get started quickly using various GitLab features. Still, you have all the flexibility to remove the ones you do not -need, or even add new ones that are not built-in. +With a [cluster management project](management_project.md) you can manage +your cluster's deployment and applications through a repository in GitLab. -## How to use this template +The Custer Management project template provides you a baseline to get +started and flexibility to customize your project to your cluster's needs. +For instance, you can: -1. [Connect your cluster to GitLab](../project/clusters/index.md#add-and-remove-clusters). -1. Create a new project for the purpose of managing your cluster from GitLab. To do so, -[create a new project from a template](../project/working_with_projects.md#built-in-templates) -and select **GitLab Cluster Management**. -1. Configure this project as a [cluster management project](management_project.md#selecting-a-cluster-management-project) -for the cluster you have integrated on the first step. -1. If you used the [GitLab Managed Apps](applications.md), refer to - [Migrating from GitLab Managed Apps](migrating_from_gma_to_project_template.md). +- Extend the CI/CD configuration. +- Configure the built-in cluster applications. +- Remove the built-in cluster applications you don't need. +- Add other cluster applications using the same structure as the ones already available. -### Components +The template contains the following [components](#available-components): -In the repository of the newly-created project, you will find: +- A pre-configured GitLab CI/CD file so that you can configure deployment pipelines. +- A pre-configured [Helmfile](https://github.com/roboll/helmfile) so that +you can manage cluster applications with [Helm v3](https://helm.sh/). +- An `applications` directory with a `helmfile.yaml` configured for each +application available in the template. -- A predefined [`.gitlab-ci.yml`](https://gitlab.com/gitlab-org/project-templates/cluster-management/-/blob/master/.gitlab-ci.yml) - file, with a CI pipeline already configured. -- A main [`helmfile.yaml`](https://gitlab.com/gitlab-org/project-templates/cluster-management/-/blob/master/helmfile.yaml) to toggle which applications you would like to manage. -- An `applications` directory with a `helmfile.yaml` configured for each application GitLab provides. +WARNING: +If you used [GitLab Managed Apps](applications.md) to manage your +cluster from GitLab, see how to [migrate from GitLab Managed Apps](migrating_from_gma_to_project_template.md) to the Cluster Management +project. -#### The `.gitlab-ci.yml` file +## Set up the management project from the Cluster Management project template + +To set up your cluster's management project off of the Cluster Management project template: + +1. [Create a new project based on the Cluster Management template](#create-a-new-project-based-on-the-cluster-management-template). +1. [Associate the cluster management project with your cluster](management_project.md#associate-the-cluster-management-project-with-the-cluster). +1. Use the [available components](#available-components) to manage your cluster. + +### Create a new project based on the Cluster Management template + +To get started, create a new project based on the Cluster Management +project template to use as a cluster management project. + +You can either create the [new project](../project/working_with_projects.md#create-a-project) +from the template or import the project from the URL. Importing +the project is useful if you are using a GitLab self-managed +instance that may not have the latest version of the template. + +To create the new project: + +- From the template: select the **GitLab Cluster Management** project template. +- Importing from the URL: use `https://gitlab.com/gitlab-org/project-templates/cluster-management.git`. + +## Available components + +Use the available components to configure your cluster: + +- [A `.gitlab-ci.yml` file](#the-gitlab-ciyml-file). +- [A main `helmfile.yml` file](#the-main-helmfileyml-file). +- [A directory with built-in applications](#built-in-applications). + +### The `.gitlab-ci.yml` file The base image used in your pipeline is built by the [cluster-applications](https://gitlab.com/gitlab-org/cluster-integration/cluster-applications) project. This image consists of a set of Bash utility scripts to support [Helm v3 releases](https://helm.sh/docs/intro/using_helm/#three-big-concepts): @@ -52,23 +81,21 @@ project. This image consists of a set of Bash utility scripts to support [Helm v facilitate the GitLab Managed Apps adoption. - `gl-helmfile {arguments}`: A thin wrapper that triggers the [Helmfile](https://github.com/roboll/helmfile) command. -#### The main `helmfile.yml` file +### The main `helmfile.yml` file This file has a list of paths to other Helmfiles for each app. They're all commented out by default, so you must uncomment -the paths for the apps that you would like to manage. +the paths for the apps that you would like to use in your cluster. -By default, each `helmfile.yaml` in these sub-paths have the attribute `installed: true`. This signifies that every time +By default, each `helmfile.yaml` in these sub-paths has the attribute `installed: true`. This means that every time the pipeline runs, Helmfile tries to either install or update your apps according to the current state of your cluster and Helm releases. If you change this attribute to `installed: false`, Helmfile tries try to uninstall this app from your cluster. [Read more](https://github.com/roboll/helmfile) about how Helmfile works. Furthermore, each app has an `applications/{app}/values.yaml` file (`applicaton/{app}/values.yaml.gotmpl` in case of GitLab Runner). This is the -place where you can define some default values for your app's Helm chart. Some apps already have defaults +place where you can define default values for your app's Helm chart. Some apps already have defaults pre-defined by GitLab. -#### Built-in applications - -The built-in applications are intended to provide an easy way to get started with various Kubernetes oriented GitLab features. +### Built-in applications The [built-in supported applications](https://gitlab.com/gitlab-org/project-templates/cluster-management/-/tree/master/applications) are: @@ -83,8 +110,3 @@ The [built-in supported applications](https://gitlab.com/gitlab-org/project-temp - [Prometheus](../infrastructure/clusters/manage/management_project_applications/prometheus.md) - [Sentry](../infrastructure/clusters/manage/management_project_applications/sentry.md) - [Vault](../infrastructure/clusters/manage/management_project_applications/vault.md) - -### Migrate from GitLab Managed Apps - -If you had GitLab Managed Apps, either One-Click or CI/CD install, read the docs on how to -[migrate from GitLab Managed Apps to project template](migrating_from_gma_to_project_template.md) diff --git a/doc/user/clusters/migrating_from_gma_to_project_template.md b/doc/user/clusters/migrating_from_gma_to_project_template.md index a7e865f619a..2da058fb5bc 100644 --- a/doc/user/clusters/migrating_from_gma_to_project_template.md +++ b/doc/user/clusters/migrating_from_gma_to_project_template.md @@ -4,23 +4,24 @@ group: Configure info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Migrating from GitLab Managed Apps to a management project template +# Migrate from GitLab Managed Apps to Cluster Management Projects -The [GitLab Managed Apps](applications.md) deprecated in GitLab 14.0. -To manage your apps through a cluster management project, you need a [GitLab Runner](../../ci/runners/index.md) available. -Then, follow the steps below. You can also watch -some recorded videos with [live examples](#live-examples). +The [GitLab Managed Apps](applications.md) were deprecated in GitLab 14.0 +in favor of [Cluster Management Projects](management_project.md). +Managing your cluster applications through a project enables you a +lot more flexibility to manage your cluster than through the late GitLab Managed Apps. +To migrate to the cluster management project you need +[GitLab Runners](../../ci/runners/index.md) +available and be familiar with [Helm](https://helm.sh/). -1. Familiarize yourself with the [management project template](management_project_template.md). -1. Create a [new project](../project/working_with_projects.md#create-a-project), either: - - From a template, selecting the **GitLab Cluster Management** project template. - - Importing the project from the URL `https://gitlab.com/gitlab-org/project-templates/cluster-management.git`. This - is useful if you are using GitLab Self-Managed and you want to use the latest version of the template. +## Migrate to a Cluster Management Project - This is your cluster management project. - If you are using a self-managed GitLab instance older than the latest one, import the cluster management project via URL from `https://gitlab.com/gitlab-org/project-templates/cluster-management.git`. -1. Go to the project associated with your cluster. -1. In your cluster's configuration page [set the cluster management project](management_project.md#selecting-a-cluster-management-project) that you just created. +To migrate from GitLab Managed Apps to a Cluster Management Project, +follow the steps below. +See also [video walk-throughs](#video-walk-throughs) with examples. + +1. Create a new project based on the [Cluster Management Project template](management_project_template.md#create-a-new-project-based-on-the-cluster-management-template). +1. [Associate your new Cluster Management Project with your cluster](management_project.md#associate-the-cluster-management-project-with-the-cluster). 1. Detect apps deployed through Helm v2 releases by using the pre-configured [`.gitlab-ci.yml`](management_project_template.md#the-gitlab-ciyml-file) file: - In case you had overwritten the default GitLab Managed Apps namespace, edit `.gitlab-ci.yml`, and make sure the script is receiving the correct namespace as an argument: @@ -125,7 +126,9 @@ you want to manage with the Cluster Management Project. For example, if you found a resource of type `ConfigMap` named `cert-manager-controller`, delete it by executing: `kubectl delete configmap -n gitlab-managed-apps cert-manager-controller`. -## Live examples +## Video walk-throughs + +You can watch these videos with examples on how to migrate from GMA to a Cluster Management project: - [Migrating from scratch using a brand new cluster management project](https://youtu.be/jCUFGWT0jS0). Also covers Helm v2 apps migration. -- [Migrating from an existing GitLab managed apps CI/CD project](https://youtu.be/U2lbBGZjZmc) +- [Migrating from an existing GitLab managed apps CI/CD project](https://youtu.be/U2lbBGZjZmc). diff --git a/doc/user/group/value_stream_analytics/index.md b/doc/user/group/value_stream_analytics/index.md index 3a7167e0907..ec0f18f70d2 100644 --- a/doc/user/group/value_stream_analytics/index.md +++ b/doc/user/group/value_stream_analytics/index.md @@ -7,7 +7,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Value Stream Analytics **(PREMIUM)** -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196455) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9 at the group level. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196455) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9 for groups. Value Stream Analytics measures the time spent to go from an [idea to production](https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/certmanager.md b/doc/user/infrastructure/clusters/manage/management_project_applications/certmanager.md index 567428ade46..9ef7bd0f3ff 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/certmanager.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/certmanager.md @@ -53,9 +53,3 @@ You can customize the installation of cert-manager by defining a management project. Refer to the [chart](https://github.com/jetstack/cert-manager) for the available configuration options. - -Support for installing the Cert Manager managed application is provided by the -GitLab Configure group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Configure group](https://about.gitlab.com/handbook/product/categories/#configure-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/cilium.md b/doc/user/infrastructure/clusters/manage/management_project_applications/cilium.md index 4e84f2c5ef4..c19bfbfb1b1 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/cilium.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/cilium.md @@ -120,9 +120,3 @@ global: enabled: - 'flow:sourceContext=namespace;destinationContext=namespace' ``` - -Support for installing the Cilium managed application is provided by the -GitLab Container Security group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Container Security group](https://about.gitlab.com/handbook/product/categories/#container-security-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/elasticstack.md b/doc/user/infrastructure/clusters/manage/management_project_applications/elasticstack.md index 85f8b6d8d3b..dbde9bd90b0 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/elasticstack.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/elasticstack.md @@ -27,8 +27,3 @@ You can customize the installation of Elastic Stack by updating the management project. Refer to the [chart](https://gitlab.com/gitlab-org/charts/elastic-stack) for all available configuration options. - -Support for installing the Elastic Stack managed application is provided by the -GitLab Monitor group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the [Monitor group](https://about.gitlab.com/handbook/product/categories/#monitor-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/falco.md b/doc/user/infrastructure/clusters/manage/management_project_applications/falco.md index dff0c3bd7bc..7bd2a4a5133 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/falco.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/falco.md @@ -93,9 +93,3 @@ You can check these logs with the following command: ```shell kubectl -n gitlab-managed-apps logs -l app=falco ``` - -Support for installing the Falco managed application is provided by the -GitLab Container Security group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Container Security group](https://about.gitlab.com/handbook/product/categories/#container-security-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/fluentd.md b/doc/user/infrastructure/clusters/manage/management_project_applications/fluentd.md index bf05f8f87d8..c5de0511c2f 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/fluentd.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/fluentd.md @@ -28,9 +28,3 @@ for the current development release of Fluentd for all available configuration o The configuration chart link points to the current development release, which may differ from the version you have installed. To ensure compatibility, switch to the specific branch or tag you are using. - -Support for installing the Fluentd managed application is provided by the -GitLab Container Security group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Container Security group](https://about.gitlab.com/handbook/product/categories/#container-security-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/ingress.md b/doc/user/infrastructure/clusters/manage/management_project_applications/ingress.md index 4f17dbab11b..5ee26db754e 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/ingress.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/ingress.md @@ -24,8 +24,3 @@ You can customize the installation of Ingress by updating the management project. Refer to the [chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress) for the available configuration options. - -Support for installing the Ingress managed application is provided by the GitLab Configure group. -If you run into unknown issues, [open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), -and ping at least 2 people from the -[Configure group](https://about.gitlab.com/handbook/product/categories/#configure-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/prometheus.md b/doc/user/infrastructure/clusters/manage/management_project_applications/prometheus.md index 3b0651bbfa9..3420f340c94 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/prometheus.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/prometheus.md @@ -25,8 +25,3 @@ You can customize the installation of Prometheus by updating the management project. Refer to the [Configuration section](https://github.com/helm/charts/tree/master/stable/prometheus#configuration) of the Prometheus chart's README for the available configuration options. - -Support for installing the Prometheus managed application is provided by the -GitLab Monitor group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the [Monitor group](https://about.gitlab.com/handbook/product/categories/#monitor-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/runner.md b/doc/user/infrastructure/clusters/manage/management_project_applications/runner.md index 56f1e07389a..841f2af7863 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/runner.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/runner.md @@ -42,9 +42,3 @@ You can customize the installation of GitLab Runner by defining management project. Refer to the [chart](https://gitlab.com/gitlab-org/charts/gitlab-runner) for the available configuration options. - -Support for installing the GitLab Runner managed application is provided by the -GitLab Runner group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Runner group](https://about.gitlab.com/handbook/product/categories/#runner-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/sentry.md b/doc/user/infrastructure/clusters/manage/management_project_applications/sentry.md index 2d7a37e2a96..300350010af 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/sentry.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/sentry.md @@ -68,9 +68,3 @@ ingress: postgresql: postgresqlPassword: example-postgresql-password ``` - -Support for installing the Sentry managed application is provided by the -GitLab Monitor group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Monitor group](https://about.gitlab.com/handbook/product/categories/#monitor-group). diff --git a/doc/user/infrastructure/clusters/manage/management_project_applications/vault.md b/doc/user/infrastructure/clusters/manage/management_project_applications/vault.md index 291321963d0..d6b4eb5c157 100644 --- a/doc/user/infrastructure/clusters/manage/management_project_applications/vault.md +++ b/doc/user/infrastructure/clusters/manage/management_project_applications/vault.md @@ -100,9 +100,3 @@ kubectl -n gitlab-managed-apps exec -it vault-0 sh This should give you your unseal keys and initial root token. Make sure to note these down and keep these safe, as they're required to unseal the Vault throughout its lifecycle. - -Support for installing the Vault managed application is provided by the -GitLab Release Management group. If you run into unknown issues, -[open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new), and ping at -least 2 people from the -[Release Management group](https://about.gitlab.com/handbook/product/categories/#release-management-group). diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md index 820c7050e34..a7f010b7852 100644 --- a/doc/user/project/clusters/add_remove_clusters.md +++ b/doc/user/project/clusters/add_remove_clusters.md @@ -9,11 +9,9 @@ info: To determine the technical writer assigned to the Stage/Group associated w > [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0. WARNING: -Creating a new cluster through the certificate-based method -is deprecated and no longer recommended. Kubernetes cluster, similar to any other -infrastructure, should be created, updated, maintained using [Infrastructure as Code](../../infrastructure/index.md). -GitLab is developing a built-in capability to create clusters with Terraform. -You can follow along in this [epic](https://gitlab.com/groups/gitlab-org/-/epics/6049). +Creating a new cluster through cluster certificates +is deprecated and no longer recommended. To create a new cluster use +[Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac). NOTE: Every new Google Cloud Platform (GCP) account receives @@ -30,29 +28,38 @@ in a few clicks. ## Create new cluster -> The certificate-based method for creating clusters from GitLab was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0. +> [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0. -As of GitLab 14.0, use [Infrastructure as Code](../../infrastructure/index.md) -to **safely create your new cluster from GitLab**. +As of GitLab 14.0, use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac) +to **safely create new clusters from GitLab**. -The certificate-based method is **deprecated** and scheduled for removal in -GitLab 15.0. However, you can still use it until then. Through -this method, you can host your cluster in EKS, GKE, on premises, and with other -providers. To host them on premises and with other providers, -use either the EKS or GKE method to guide you through and enter your cluster's -settings manually: +Creating clusters from GitLab using cluster certificates is still available on the +GitLab UI but was **deprecated** in GitLab 14.0 and is scheduled for removal in +GitLab 15.0. We don't recommend using this method. + +You can create a new cluster hosted in EKS, GKE, on premises, and with other +providers using cluster certificates: - [New cluster hosted on Google Kubernetes Engine (GKE)](add_gke_clusters.md). - [New cluster hosted on Amazon Elastic Kubernetes Service (EKS)](add_eks_clusters.md). +To host them on premises and with other providers, you can use Terraform +or your preferred tool of choice to create and connect a cluster with GitLab. +The [GitLab Terraform provider](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_cluster) +supports connecting existing clusters using the certificate-based connection method. + ## Add existing cluster -If you already have a cluster and want to integrate it with GitLab, see how to -[add an existing cluster](add_existing_cluster.md). +As of GitLab 14.0, use the [GitLab Kubernetes Agent](../../clusters/agent/index.md) +to connect your cluster to GitLab. + +Alternativelly, you can [add an existing cluster](add_existing_cluster.md) +through the certificate-based method, but we don't recommend using this method for [security implications](index.md#security-implications). ## Configure your cluster -As of GitLab 14.0, use the [GitLab Kubernetes Agent](../../clusters/agent/index.md) to configure your cluster. +As of GitLab 14.0, use the [GitLab Kubernetes Agent](../../clusters/agent/index.md) +to configure your cluster. ## Disable a cluster diff --git a/lib/gitlab/branch_push_merge_commit_analyzer.rb b/lib/gitlab/branch_push_merge_commit_analyzer.rb index a8f601f2451..ddf2086363c 100644 --- a/lib/gitlab/branch_push_merge_commit_analyzer.rb +++ b/lib/gitlab/branch_push_merge_commit_analyzer.rb @@ -114,7 +114,7 @@ module Gitlab # If child commit is a direct ancestor, its first parent is also a direct ancestor. # We assume direct ancestors matches the trail of the target branch over time, # This assumption is correct most of the time, especially for gitlab managed merges, - # but there are exception cases which can't be solved (https://stackoverflow.com/a/49754723/474597) + # but there are exception cases which can't be solved. def mark_all_direct_ancestors(commit) loop do commit = get_commit(commit.parent_ids.first) diff --git a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml index dd88953b9a4..841f17767eb 100644 --- a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml @@ -21,7 +21,7 @@ # # The deploy stage copies the exe and msi from build stage to a network drive # You need to have the network drive mapped as Local System user for gitlab-runner service to see it -# The best way to persist the mapping is via a scheduled task (see: https://stackoverflow.com/a/7867064/1288473), +# The best way to persist the mapping is via a scheduled task # running the following batch command: net use P: \\x.x.x.x\Projects /u:your_user your_pass /persistent:yes # place project specific paths in variables to make the rest of the script more generic diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index fdc598c025a..2d31049a0c9 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -3,7 +3,6 @@ module Gitlab module Ci class Trace - # This was inspired from: http://stackoverflow.com/a/10219411/1520132 class Stream BUFFER_SIZE = 4096 LIMIT_SIZE = 500.kilobytes diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index f3f0d0305d3..385ac40cf13 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -198,14 +198,30 @@ module Gitlab ::ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).map(&:name) end - def self.db_config_name(ar_connection) - if ar_connection.respond_to?(:pool) && - ar_connection.pool.respond_to?(:db_config) && - ar_connection.pool.db_config.respond_to?(:name) - return ar_connection.pool.db_config.name - end + def self.db_config_for_connection(connection) + return unless connection - 'unknown' + # The LB connection proxy does not have a direct db_config + # that can be referenced + return if connection.is_a?(::Gitlab::Database::LoadBalancing::ConnectionProxy) + + # During application init we might receive `NullPool` + return unless connection.respond_to?(:pool) && + connection.pool.respond_to?(:db_config) + + connection.pool.db_config + end + + # At the moment, the connection can only be retrieved by + # Gitlab::Database::LoadBalancer#read or #read_write or from the + # ActiveRecord directly. Therefore, if the load balancer doesn't + # recognize the connection, this method returns the primary role + # directly. In future, we may need to check for other sources. + # Expected returned names: + # main, main_replica, ci, ci_replica, unknown + def self.db_config_name(connection) + db_config = db_config_for_connection(connection) + db_config&.name || 'unknown' end def self.read_only? diff --git a/lib/gitlab/database/load_balancing.rb b/lib/gitlab/database/load_balancing.rb index d4f168ba188..bbfbf83222f 100644 --- a/lib/gitlab/database/load_balancing.rb +++ b/lib/gitlab/database/load_balancing.rb @@ -79,24 +79,12 @@ module Gitlab ].freeze # Returns the role (primary/replica) of the database the connection is - # connecting to. At the moment, the connection can only be retrieved by - # Gitlab::Database::LoadBalancer#read or #read_write or from the - # ActiveRecord directly. Therefore, if the load balancer doesn't - # recognize the connection, this method returns the primary role - # directly. In future, we may need to check for other sources. + # connecting to. def self.db_role_for_connection(connection) - return ROLE_UNKNOWN unless connection + db_config = Database.db_config_for_connection(connection) + return ROLE_UNKNOWN unless db_config - # The connection proxy does not have a role assigned - # as this is dependent on a execution context - return ROLE_UNKNOWN if connection.is_a?(ConnectionProxy) - - # During application init we might receive `NullPool` - return ROLE_UNKNOWN unless connection.respond_to?(:pool) && - connection.pool.respond_to?(:db_config) && - connection.pool.db_config.respond_to?(:name) - - if connection.pool.db_config.name.ends_with?(LoadBalancer::REPLICA_SUFFIX) + if db_config.name.ends_with?(LoadBalancer::REPLICA_SUFFIX) ROLE_REPLICA else ROLE_PRIMARY diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 2a996dc77c2..9968096b1f6 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -73,6 +73,7 @@ module Gitlab end end + # @deprecated Use `create_table` in V2 instead # # Creates a new table, optionally allowing the caller to add check constraints to the table. # Aside from that addition, this method should behave identically to Rails' `create_table` method. diff --git a/lib/gitlab/database/migration_helpers/v2.rb b/lib/gitlab/database/migration_helpers/v2.rb index 79d69676c44..855c9908e4a 100644 --- a/lib/gitlab/database/migration_helpers/v2.rb +++ b/lib/gitlab/database/migration_helpers/v2.rb @@ -6,6 +6,65 @@ module Gitlab module V2 include Gitlab::Database::MigrationHelpers + # Superseded by `create_table` override below + def create_table_with_constraints(*_) + raise <<~EOM + #create_table_with_constraints is not supported anymore - use #create_table instead, for example: + + create_table :db_guides do |t| + t.bigint :stars, default: 0, null: false + t.text :title, limit: 128 + t.text :notes, limit: 1024 + + t.check_constraint 'stars > 1000', name: 'so_many_stars' + end + + See https://docs.gitlab.com/ee/development/database/strings_and_the_text_data_type.html + EOM + end + + # Creates a new table, optionally allowing the caller to add text limit constraints to the table. + # This method only extends Rails' `create_table` method + # + # Example: + # + # create_table :db_guides do |t| + # t.bigint :stars, default: 0, null: false + # t.text :title, limit: 128 + # t.text :notes, limit: 1024 + # + # t.check_constraint 'stars > 1000', name: 'so_many_stars' + # end + # + # See Rails' `create_table` for more info on the available arguments. + # + # When adding foreign keys to other tables, consider wrapping the call into a with_lock_retries block + # to avoid traffic stalls. + def create_table(table_name, *args, **kwargs, &block) + helper_context = self + + super do |t| + t.define_singleton_method(:text) do |column_name, **kwargs| + limit = kwargs.delete(:limit) + + super(column_name, **kwargs) + + if limit + # rubocop:disable GitlabSecurity/PublicSend + name = helper_context.send(:text_limit_name, table_name, column_name) + # rubocop:enable GitlabSecurity/PublicSend + + column_name = helper_context.quote_column_name(column_name) + definition = "char_length(#{column_name}) <= #{limit}" + + t.check_constraint(definition, name: name) + end + end + + t.instance_eval(&block) unless block.nil? + end + end + # Executes the block with a retry mechanism that alters the +lock_timeout+ and +sleep_time+ between attempts. # The timings can be controlled via the +timing_configuration+ parameter. # If the lock was not acquired within the retry period, a last attempt is made without using +lock_timeout+. diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb index b110d39818d..4b490ae0d26 100644 --- a/lib/gitlab/email/handler/create_issue_handler.rb +++ b/lib/gitlab/email/handler/create_issue_handler.rb @@ -55,7 +55,7 @@ module Gitlab private def create_issue - Issues::CreateService.new( + ::Issues::CreateService.new( project: project, current_user: author, params: { diff --git a/lib/gitlab/email/handler/service_desk_handler.rb b/lib/gitlab/email/handler/service_desk_handler.rb index 84b55079cea..74c8d0a1fd7 100644 --- a/lib/gitlab/email/handler/service_desk_handler.rb +++ b/lib/gitlab/email/handler/service_desk_handler.rb @@ -71,7 +71,7 @@ module Gitlab end def create_issue! - @issue = Issues::CreateService.new( + @issue = ::Issues::CreateService.new( project: project, current_user: User.support_bot, params: { diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index d5ced2045f5..a1855132b0c 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -5,7 +5,6 @@ require_dependency 'gitlab/encoding_helper' module Gitlab module Git # The ID of empty tree. - # See http://stackoverflow.com/a/40884093/1856239 and # https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012 EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' BLANK_SHA = ('0' * 40).freeze diff --git a/lib/gitlab/issues/rebalancing/state.rb b/lib/gitlab/issues/rebalancing/state.rb new file mode 100644 index 00000000000..dce165a3489 --- /dev/null +++ b/lib/gitlab/issues/rebalancing/state.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module Gitlab + module Issues + module Rebalancing + class State + REDIS_EXPIRY_TIME = 10.days + MAX_NUMBER_OF_CONCURRENT_REBALANCES = 5 + NAMESPACE = 1 + PROJECT = 2 + + def initialize(root_namespace, projects) + @root_namespace = root_namespace + @projects = projects + @rebalanced_container_type = @root_namespace.is_a?(Group) ? NAMESPACE : PROJECT + @rebalanced_container_id = @rebalanced_container_type == NAMESPACE ? @root_namespace.id : projects.take.id # rubocop:disable CodeReuse/ActiveRecord + end + + def track_new_running_rebalance + with_redis do |redis| + redis.multi do |multi| + # we trigger re-balance for namespaces(groups) or specific user project + value = "#{rebalanced_container_type}/#{rebalanced_container_id}" + multi.sadd(concurrent_running_rebalances_key, value) + multi.expire(concurrent_running_rebalances_key, REDIS_EXPIRY_TIME) + end + end + end + + def concurrent_running_rebalances_count + with_redis { |redis| redis.scard(concurrent_running_rebalances_key).to_i } + end + + def rebalance_in_progress? + all_rebalanced_containers = with_redis { |redis| redis.smembers(concurrent_running_rebalances_key) } + + is_running = case rebalanced_container_type + when NAMESPACE + namespace_ids = all_rebalanced_containers.map {|string| string.split("#{NAMESPACE}/").second.to_i }.compact + namespace_ids.include?(root_namespace.id) + when PROJECT + project_ids = all_rebalanced_containers.map {|string| string.split("#{PROJECT}/").second.to_i }.compact + project_ids.include?(projects.take.id) # rubocop:disable CodeReuse/ActiveRecord + else + false + end + + refresh_keys_expiration if is_running + + is_running + end + + def can_start_rebalance? + rebalance_in_progress? || too_many_rebalances_running? + end + + def cache_issue_ids(issue_ids) + with_redis do |redis| + values = issue_ids.map { |issue| [issue.relative_position, issue.id] } + + redis.multi do |multi| + multi.zadd(issue_ids_key, values) unless values.blank? + multi.expire(issue_ids_key, REDIS_EXPIRY_TIME) + end + end + end + + def get_cached_issue_ids(index, limit) + with_redis do |redis| + redis.zrange(issue_ids_key, index, index + limit - 1) + end + end + + def cache_current_index(index) + with_redis { |redis| redis.set(current_index_key, index, ex: REDIS_EXPIRY_TIME) } + end + + def get_current_index + with_redis { |redis| redis.get(current_index_key).to_i } + end + + def cache_current_project_id(project_id) + with_redis { |redis| redis.set(current_project_key, project_id, ex: REDIS_EXPIRY_TIME) } + end + + def get_current_project_id + with_redis { |redis| redis.get(current_project_key) } + end + + def issue_count + @issue_count ||= with_redis { |redis| redis.zcard(issue_ids_key)} + end + + def remove_current_project_id_cache + with_redis { |redis| redis.del(current_project_key)} + end + + def refresh_keys_expiration + with_redis do |redis| + redis.multi do |multi| + multi.expire(issue_ids_key, REDIS_EXPIRY_TIME) + multi.expire(current_index_key, REDIS_EXPIRY_TIME) + multi.expire(current_project_key, REDIS_EXPIRY_TIME) + multi.expire(concurrent_running_rebalances_key, REDIS_EXPIRY_TIME) + end + end + end + + def cleanup_cache + with_redis do |redis| + redis.multi do |multi| + multi.del(issue_ids_key) + multi.del(current_index_key) + multi.del(current_project_key) + multi.srem(concurrent_running_rebalances_key, "#{rebalanced_container_type}/#{rebalanced_container_id}") + end + end + end + + private + + attr_accessor :root_namespace, :projects, :rebalanced_container_type, :rebalanced_container_id + + def too_many_rebalances_running? + concurrent_running_rebalances_count <= MAX_NUMBER_OF_CONCURRENT_REBALANCES + end + + def redis_key_prefix + "gitlab:issues-position-rebalances" + end + + def issue_ids_key + "#{redis_key_prefix}:#{root_namespace.id}" + end + + def current_index_key + "#{issue_ids_key}:current_index" + end + + def current_project_key + "#{issue_ids_key}:current_project_id" + end + + def concurrent_running_rebalances_key + "#{redis_key_prefix}:running_rebalances" + end + + def with_redis(&blk) + Gitlab::Redis::SharedState.with(&blk) # rubocop: disable CodeReuse/ActiveRecord + end + end + end + end +end diff --git a/lib/gitlab/marginalia/comment.rb b/lib/gitlab/marginalia/comment.rb index ee15d3b1812..f635f41ec39 100644 --- a/lib/gitlab/marginalia/comment.rb +++ b/lib/gitlab/marginalia/comment.rb @@ -41,6 +41,10 @@ module Gitlab def endpoint_id Labkit::Context.current&.get_attribute(:caller_id) end + + def db_config_name + ::Gitlab::Database.db_config_name(marginalia_adapter) + end end end end diff --git a/lib/gitlab/pagination/keyset/order.rb b/lib/gitlab/pagination/keyset/order.rb index 4a12e612cea..80726fc8efd 100644 --- a/lib/gitlab/pagination/keyset/order.rb +++ b/lib/gitlab/pagination/keyset/order.rb @@ -219,7 +219,7 @@ module Gitlab column_definition.column_expression.dup.as(column_definition.attribute_name).to_sql end - scope = scope.select(*scope.arel.projections, *additional_projections) if additional_projections + scope = scope.reselect(*scope.arel.projections, *additional_projections) unless additional_projections.blank? scope end diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb index ff17ecf8024..c5cf3262039 100644 --- a/lib/gitlab/quick_actions/issue_actions.rb +++ b/lib/gitlab/quick_actions/issue_actions.rb @@ -267,7 +267,7 @@ module Gitlab private def zoom_link_service - Issues::ZoomLinkService.new(project: quick_action_target.project, current_user: current_user, params: { issue: quick_action_target }) + ::Issues::ZoomLinkService.new(project: quick_action_target.project, current_user: current_user, params: { issue: quick_action_target }) end end end diff --git a/lib/gitlab/slash_commands/issue_close.rb b/lib/gitlab/slash_commands/issue_close.rb index 3dad7216983..5d33f2fe62d 100644 --- a/lib/gitlab/slash_commands/issue_close.rb +++ b/lib/gitlab/slash_commands/issue_close.rb @@ -29,7 +29,7 @@ module Gitlab private def close_issue(issue:) - Issues::CloseService.new(project: project, current_user: current_user).execute(issue) + ::Issues::CloseService.new(project: project, current_user: current_user).execute(issue) end def presenter(issue) diff --git a/lib/gitlab/slash_commands/issue_move.rb b/lib/gitlab/slash_commands/issue_move.rb index 0612663017c..9f10da247d7 100644 --- a/lib/gitlab/slash_commands/issue_move.rb +++ b/lib/gitlab/slash_commands/issue_move.rb @@ -29,11 +29,11 @@ module Gitlab return Gitlab::SlashCommands::Presenters::Access.new.not_found end - new_issue = Issues::MoveService.new(project: project, current_user: current_user) + new_issue = ::Issues::MoveService.new(project: project, current_user: current_user) .execute(old_issue, target_project) presenter(new_issue).present(old_issue) - rescue Issues::MoveService::MoveError => e + rescue ::Issues::MoveService::MoveError => e presenter(old_issue).display_move_error(e.message) end diff --git a/lib/gitlab/slash_commands/issue_new.rb b/lib/gitlab/slash_commands/issue_new.rb index fab016d2e1b..ef368767689 100644 --- a/lib/gitlab/slash_commands/issue_new.rb +++ b/lib/gitlab/slash_commands/issue_new.rb @@ -33,7 +33,7 @@ module Gitlab private def create_issue(title:, description:) - Issues::CreateService.new(project: project, current_user: current_user, params: { title: title, description: description }, spam_params: nil).execute + ::Issues::CreateService.new(project: project, current_user: current_user, params: { title: title, description: description }, spam_params: nil).execute end def presenter(issue) diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 910c8397f20..9a091e8819c 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -918,7 +918,7 @@ module Gitlab jira: count(::JiraImportState.where(time_period)), # rubocop: disable CodeReuse/ActiveRecord fogbugz: projects_imported_count('fogbugz', time_period), phabricator: projects_imported_count('phabricator', time_period), - csv: count(Issues::CsvImport.where(time_period)) # rubocop: disable CodeReuse/ActiveRecord + csv: count(::Issues::CsvImport.where(time_period)) # rubocop: disable CodeReuse/ActiveRecord } end @@ -934,7 +934,7 @@ module Gitlab project_imports = distinct_count(::Project.where(time_period).where.not(import_type: nil), :creator_id) bulk_imports = distinct_count(::BulkImport.where(time_period), :user_id) jira_issue_imports = distinct_count(::JiraImportState.where(time_period), :user_id) - csv_issue_imports = distinct_count(Issues::CsvImport.where(time_period), :user_id) + csv_issue_imports = distinct_count(::Issues::CsvImport.where(time_period), :user_id) group_imports = distinct_count(::GroupImportState.where(time_period), :user_id) add(project_imports, bulk_imports, jira_issue_imports, csv_issue_imports, group_imports) diff --git a/lib/support/logrotate/gitlab b/lib/support/logrotate/gitlab index c34db47e214..43fa8eb963c 100644 --- a/lib/support/logrotate/gitlab +++ b/lib/support/logrotate/gitlab @@ -1,5 +1,4 @@ # GitLab logrotate settings -# based on: http://stackoverflow.com/a/4883967 /home/git/gitlab/log/*.log { su git git diff --git a/lib/tasks/gitlab/sidekiq.rake b/lib/tasks/gitlab/sidekiq.rake index d3060d92e88..90ed91221ae 100644 --- a/lib/tasks/gitlab/sidekiq.rake +++ b/lib/tasks/gitlab/sidekiq.rake @@ -86,9 +86,8 @@ namespace :gitlab do # 3: high priority # 5: _super_ high priority, this should only be used for _very_ important queues # - # As per http://stackoverflow.com/a/21241357/290102 the formula for calculating - # the likelihood of a job being popped off a queue (given all queues have work - # to perform) is: + # The formula for calculating the likelihood of a job being popped off a queue + # (given all queues have work to perform) is: # # chance = (queue weight / total weight of all queues) * 100 BANNER diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b58dd6bed68..5d32091cd54 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2872,10 +2872,10 @@ msgstr "" msgid "Admin|Quarterly reconciliation will occur on %{qrtlyDate}" msgstr "" -msgid "Admin|The number of max seats used for your namespace is currently exceeding the number of seats in your subscription. On %{qrtlyDate}, GitLab will process a quarterly reconciliation and automatically bill you a prorated amount for the overage. There is no action needed from you. If you have a credit card on file, it will be charged. Otherwise, you will receive an invoice." +msgid "Admin|The number of max seats in your namespace exceeds the number of seats in your subscription. On %{qrtlyDate}, quarterly reconciliation occurs and you are automatically billed a prorated amount for the overage. No action is needed from you. If you have a credit card on file, it will be charged. Otherwise, you will receive an invoice. For more information about the timing of the invoicing process, view the documentation." msgstr "" -msgid "Admin|The number of maximum users for your instance is currently exceeding the number of users in license. On %{qrtlyDate}, GitLab will process a quarterly reconciliation and automatically bill you a prorated amount for the overage. There is no action needed from you. If you have a credit card on file, it will be charged. Otherwise, you will receive an invoice." +msgid "Admin|The number of max users in your instance exceeds the number of users in your license. On %{qrtlyDate}, quarterly reconciliation occurs and you are automatically billed a prorated amount for the overage. No action is needed from you. If you have a credit card on file, it will be charged. Otherwise, you will receive an invoice. For more information about the timing of the invoicing process, view the documentation." msgstr "" msgid "Admin|View pending user approvals" diff --git a/rubocop/cop/migration/add_limit_to_text_columns.rb b/rubocop/cop/migration/add_limit_to_text_columns.rb index f45551e60a4..b5780e87c19 100644 --- a/rubocop/cop/migration/add_limit_to_text_columns.rb +++ b/rubocop/cop/migration/add_limit_to_text_columns.rb @@ -13,8 +13,13 @@ module RuboCop class AddLimitToTextColumns < RuboCop::Cop::Cop include MigrationHelpers + TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE = 2021_09_10_00_00_00 + MSG = 'Text columns should always have a limit set (255 is suggested). ' \ - 'You can add a limit to a `text` column by using `add_text_limit`' + 'You can add a limit to a `text` column by using `add_text_limit` or by using `.text... limit: 255` inside `create_table`' + + TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED = 'Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. ' \ + 'You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table`' def_node_matcher :reverting?, <<~PATTERN (def :down ...) @@ -37,15 +42,29 @@ module RuboCop node.each_descendant(:send) do |send_node| next unless text_operation?(send_node) - # We require a limit for the same table and attribute name - if text_limit_missing?(node, *table_and_attribute_name(send_node)) - add_offense(send_node, location: :selector) + if text_operation_with_limit?(send_node) + add_offense(send_node, location: :selector, message: TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED) if version(node) < TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE + else + # We require a limit for the same table and attribute name + if text_limit_missing?(node, *table_and_attribute_name(send_node)) + add_offense(send_node, location: :selector) + end end end end private + def text_operation_with_limit?(node) + migration_method = node.children[1] + + return unless migration_method == :text + + if attributes = node.children[3] + attributes.pairs.find { |pair| pair.key.value == :limit }.present? + end + end + def text_operation?(node) # Don't complain about text arrays return false if array_column?(node) diff --git a/spec/frontend/__helpers__/local_storage_helper.js b/spec/frontend/__helpers__/local_storage_helper.js index 21749fd8070..cf75b0b53fe 100644 --- a/spec/frontend/__helpers__/local_storage_helper.js +++ b/spec/frontend/__helpers__/local_storage_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.localStorage` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for localStorage */ diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index 3755778e5c1..14082857053 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.location` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for window.location */ diff --git a/spec/frontend/ide/services/terminals_spec.js b/spec/frontend/ide/services/terminals_spec.js new file mode 100644 index 00000000000..788fdb6471c --- /dev/null +++ b/spec/frontend/ide/services/terminals_spec.js @@ -0,0 +1,51 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as terminalService from '~/ide/services/terminals'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_PROJECT_PATH = 'lorem/ipsum/dolar'; +const TEST_BRANCH = 'ref'; + +describe('~/ide/services/terminals', () => { + let axiosSpy; + let mock; + const prevRelativeUrlRoot = gon.relative_url_root; + + beforeEach(() => { + axiosSpy = jest.fn().mockReturnValue([200, {}]); + + mock = new MockAdapter(axios); + mock.onPost(/.*/).reply((...args) => axiosSpy(...args)); + }); + + afterEach(() => { + gon.relative_url_root = prevRelativeUrlRoot; + mock.restore(); + }); + + it.each` + method | relativeUrlRoot | url + ${'checkConfig'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'create'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals`} + `( + 'when $method called, posts request to $url (relative_url_root=$relativeUrlRoot)', + async ({ method, url, relativeUrlRoot }) => { + gon.relative_url_root = relativeUrlRoot; + + await terminalService[method](TEST_PROJECT_PATH, TEST_BRANCH); + + expect(axiosSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: JSON.stringify({ + branch: TEST_BRANCH, + format: 'json', + }), + url, + }), + ); + }, + ); +}); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 72a30c30b50..8d79a5eed35 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -68,8 +68,8 @@ describe('IssuesListApp component', () => { hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, + isProject: true, isSignedIn: true, - issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', rssPath: 'rss/path', @@ -191,7 +191,7 @@ describe('IssuesListApp component', () => { setWindowLocation(search); wrapper = mountComponent({ - provide: { ...defaultProvide, isSignedIn: true }, + provide: { isSignedIn: true }, mountFn: mount, }); @@ -208,7 +208,15 @@ describe('IssuesListApp component', () => { describe('when user is not signed in', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); + wrapper = mountComponent({ provide: { isSignedIn: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + + describe('when in a group context', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { isProject: false } }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -625,72 +633,89 @@ describe('IssuesListApp component', () => { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/1', iid: '101', - title: 'Issue one', + reference: 'group/project#1', + webPath: '/group/project/-/issues/1', }; const issueTwo = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/2', iid: '102', - title: 'Issue two', + reference: 'group/project#2', + webPath: '/group/project/-/issues/2', }; const issueThree = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/3', iid: '103', - title: 'Issue three', + reference: 'group/project#3', + webPath: '/group/project/-/issues/3', }; const issueFour = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/4', iid: '104', - title: 'Issue four', + reference: 'group/project#4', + webPath: '/group/project/-/issues/4', }; - const response = { + const response = (isProject = true) => ({ data: { - project: { + [isProject ? 'project' : 'group']: { issues: { ...defaultQueryResponse.data.project.issues, nodes: [issueOne, issueTwo, issueThree, issueFour], }, }, }, - }; - - beforeEach(() => { - wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) }); - jest.runOnlyPendingTimers(); }); describe('when successful', () => { - describe.each` - description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId - ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} - ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} - ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} - ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} - `( - 'when moving issue $description', - ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - it('makes API call to reorder the issue', async () => { - findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - - await waitForPromises(); - - expect(axiosMock.history.put[0]).toMatchObject({ - url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'), - data: JSON.stringify({ - move_before_id: getIdFromGraphQLId(moveBeforeId), - move_after_id: getIdFromGraphQLId(moveAfterId), - }), + describe.each([true, false])('when isProject=%s', (isProject) => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isProject }, + issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), + }); + jest.runOnlyPendingTimers(); }); - }); - }, - ); + + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(issueToMove.webPath, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + group_full_path: isProject ? undefined : defaultProvide.fullPath, + }), + }); + }); + }, + ); + }); }); describe('when unsuccessful', () => { + beforeEach(() => { + wrapper = mountComponent({ + issuesQueryResponse: jest.fn().mockResolvedValue(response()), + }); + jest.runOnlyPendingTimers(); + }); + it('displays an error message', async () => { - axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500); + axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 0f9677cff4b..720f9cac986 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -29,6 +29,7 @@ export const getIssuesQueryResponse = { updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, userDiscussionsCount: 4, + webPath: 'project/-/issues/789', webUrl: 'project/-/issues/789', assignees: { nodes: [ diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index fc5eeee9687..455db325066 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -70,8 +70,7 @@ describe('Shortcuts', () => { const mdShortcuts = $(this).data('md-shortcuts'); // jQuery.map() automatically unwraps arrays, so we - // have to double wrap the array to counteract this: - // https://stackoverflow.com/a/4875669/1063392 + // have to double wrap the array to counteract this return mdShortcuts ? [mdShortcuts] : undefined; }) .get(); diff --git a/spec/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/graphql/mutations/custom_emoji/destroy_spec.rb new file mode 100644 index 00000000000..4667812cc80 --- /dev/null +++ b/spec/graphql/mutations/custom_emoji/destroy_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomEmoji::Destroy do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group) } + + let(:args) { { id: custom_emoji.to_global_id } } + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + context 'field tests' do + subject { described_class } + + it { is_expected.to have_graphql_arguments(:id) } + it { is_expected.to have_graphql_field(:custom_emoji) } + end + + shared_examples 'does not delete custom emoji' do + it 'raises exception' do + expect { subject } + .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + shared_examples 'deletes custom emoji' do + it 'returns deleted custom emoji' do + result = subject + + expect(result[:custom_emoji][:name]).to eq(custom_emoji.name) + end + end + + describe '#resolve' do + subject { mutation.resolve(**args) } + + context 'when the user' do + context 'has no permissions' do + it_behaves_like 'does not delete custom emoji' + end + + context 'when the user is developer and not the owner of custom emoji' do + before do + group.add_developer(user) + end + + it_behaves_like 'does not delete custom emoji' + end + end + + context 'when user' do + context 'is maintainer' do + before do + group.add_maintainer(user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is owner' do + before do + group.add_owner(user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is developer and creator of the emoji' do + before do + group.add_developer(user) + custom_emoji.update_attribute(:creator, user) + end + + it_behaves_like 'deletes custom emoji' + end + end + end +end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 898e5d15549..261037ccceb 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -318,8 +318,8 @@ RSpec.describe IssuesHelper do has_any_issues: project_issues(project).exists?.to_s, import_csv_issues_path: '#', initial_email: project.new_issuable_address(current_user, 'issue'), + is_project: 'true', is_signed_in: current_user.present?.to_s, - issues_path: project_issues_path(project), jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), @@ -332,11 +332,11 @@ RSpec.describe IssuesHelper do sign_in_path: new_user_session_path } - expect(helper.issues_list_data(project, current_user, finder)).to include(expected) + expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected) end end - describe '#issues_list_data' do + describe '#project_issues_list_data' do context 'when user is signed in' do it_behaves_like 'issues list data' do let(:current_user) { double.as_null_object } @@ -350,6 +350,33 @@ RSpec.describe IssuesHelper do end end + describe '#group_issues_list_data' do + let(:group) { create(:group) } + let(:current_user) { double.as_null_object } + let(:issues) { [] } + + it 'returns expected result' do + allow(helper).to receive(:current_user).and_return(current_user) + allow(helper).to receive(:can?).and_return(true) + allow(helper).to receive(:image_path).and_return('#') + allow(helper).to receive(:url_for).and_return('#') + + expected = { + autocomplete_award_emojis_path: autocomplete_award_emojis_path, + calendar_path: '#', + empty_state_svg_path: '#', + full_path: group.full_path, + has_any_issues: issues.to_a.any?.to_s, + is_signed_in: current_user.present?.to_s, + jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), + rss_path: '#', + sign_in_path: new_user_session_path + } + + expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected) + end + end + describe '#issue_manual_ordering_class' do context 'when sorting by relative position' do before do diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb index 97ef09e320a..11f5f2895d3 100644 --- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::MigrationHelpers::V2 do include Database::TriggerHelpers + include Database::TableSchemaHelpers let(:migration) do ActiveRecord::Migration.new.extend(described_class) @@ -221,6 +222,34 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do end end + describe '#create_table' do + let(:table_name) { :test_table } + let(:column_attributes) do + [ + { name: 'id', sql_type: 'bigint', null: false, default: nil }, + { name: 'created_at', sql_type: 'timestamp with time zone', null: false, default: nil }, + { name: 'updated_at', sql_type: 'timestamp with time zone', null: false, default: nil }, + { name: 'some_id', sql_type: 'integer', null: false, default: nil }, + { name: 'active', sql_type: 'boolean', null: false, default: 'true' }, + { name: 'name', sql_type: 'text', null: true, default: nil } + ] + end + + context 'using a limit: attribute on .text' do + it 'creates the table as expected' do + migration.create_table table_name do |t| + t.timestamps_with_timezone + t.integer :some_id, null: false + t.boolean :active, null: false, default: true + t.text :name, limit: 100 + end + + expect_table_columns_to_match(column_attributes, table_name) + expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 100') + end + end + end + describe '#with_lock_retries' do let(:model) do ActiveRecord::Migration.new.extend(described_class) diff --git a/spec/lib/gitlab/database/schema_migrations/context_spec.rb b/spec/lib/gitlab/database/schema_migrations/context_spec.rb index 1f1943d00a3..9ff24a7906e 100644 --- a/spec/lib/gitlab/database/schema_migrations/context_spec.rb +++ b/spec/lib/gitlab/database/schema_migrations/context_spec.rb @@ -124,8 +124,4 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do end end end - - def skip_if_multiple_databases_not_setup - skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) - end end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 35fe2c41c0c..a9a8d5e6314 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -155,6 +155,34 @@ RSpec.describe Gitlab::Database do it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'} end + describe '.db_config_for_connection' do + context 'when the regular connection is used' do + it 'returns db_config' do + connection = ActiveRecord::Base.retrieve_connection + + expect(described_class.db_config_for_connection(connection)).to eq(connection.pool.db_config) + end + end + + context 'when the connection is LoadBalancing::ConnectionProxy' do + it 'returns nil' do + lb_config = ::Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + lb = ::Gitlab::Database::LoadBalancing::LoadBalancer.new(lb_config) + proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) + + expect(described_class.db_config_for_connection(proxy)).to be_nil + end + end + + context 'when the pool is a NullPool' do + it 'returns nil' do + connection = double(:active_record_connection, pool: ActiveRecord::ConnectionAdapters::NullPool.new) + + expect(described_class.db_config_for_connection(connection)).to be_nil + end + end + end + describe '.db_config_name' do it 'returns the db_config name for the connection' do connection = ActiveRecord::Base.connection @@ -162,14 +190,6 @@ RSpec.describe Gitlab::Database do expect(described_class.db_config_name(connection)).to be_a(String) expect(described_class.db_config_name(connection)).to eq(connection.pool.db_config.name) end - - context 'when the pool is a NullPool' do - it 'returns unknown' do - connection = double(:active_record_connection, pool: ActiveRecord::ConnectionAdapters::NullPool.new) - - expect(described_class.db_config_name(connection)).to eq('unknown') - end - end end describe '#true_value' do diff --git a/spec/lib/gitlab/issues/rebalancing/state_spec.rb b/spec/lib/gitlab/issues/rebalancing/state_spec.rb new file mode 100644 index 00000000000..bdd0dbd365d --- /dev/null +++ b/spec/lib/gitlab/issues/rebalancing/state_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_state do + shared_examples 'issues rebalance caching' do + describe '#track_new_running_rebalance' do + it 'caches a project id to track caching in progress' do + expect { rebalance_caching.track_new_running_rebalance }.to change { rebalance_caching.concurrent_running_rebalances_count }.from(0).to(1) + end + end + + describe '#set and get current_index' do + it 'returns zero as current index when index not cached' do + expect(rebalance_caching.get_current_index).to eq(0) + end + + it 'returns cached current index' do + expect { rebalance_caching.cache_current_index(123) }.to change { rebalance_caching.get_current_index }.from(0).to(123) + end + end + + describe '#set and get current_project' do + it 'returns nil if there is no project_id cached' do + expect(rebalance_caching.get_current_project_id).to be_nil + end + + it 'returns cached current project_id' do + expect { rebalance_caching.cache_current_project_id(456) }.to change { rebalance_caching.get_current_project_id }.from(nil).to('456') + end + end + + describe "#rebalance_in_progress?" do + it 'return zero if no re-balances are running' do + expect(rebalance_caching.concurrent_running_rebalances_count).to eq(0) + end + + it 'return false if no re-balances are running' do + expect(rebalance_caching.rebalance_in_progress?).to be false + end + + it 'return true a re-balance for given project/namespace is running' do + rebalance_caching.track_new_running_rebalance + + expect(rebalance_caching.rebalance_in_progress?).to be true + end + end + + context 'caching issue ids' do + context 'with no issue ids cached' do + it 'returns zero when there are no cached issue ids' do + expect(rebalance_caching.issue_count).to eq(0) + end + + it 'returns empty array when there are no cached issue ids' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq([]) + end + end + + context 'with cached issue ids' do + before do + generate_and_cache_issues_ids(count: 3) + end + + it 'returns count of cached issue ids' do + expect(rebalance_caching.issue_count).to eq(3) + end + + it 'returns array of issue ids' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(1 2 3)) + end + + it 'limits returned values' do + expect(rebalance_caching.get_cached_issue_ids(0, 2)).to eq(%w(1 2)) + end + + context 'when caching duplicate issue_ids' do + before do + generate_and_cache_issues_ids(count: 3, position_offset: 3, position_direction: -1) + end + + it 'does not cache duplicate issues' do + expect(rebalance_caching.issue_count).to eq(3) + end + + it 'returns cached issues with latest scores' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(3 2 1)) + end + end + end + end + + context 'when setting expiration' do + context 'when tracking new rebalance' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.track_new_running_rebalance + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting current index' do + it 'returns as expiring for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.cache_current_index(123) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting current project id' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.cache_current_project_id(456) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting cached issue ids' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be < 0 + end + end + + it 'has expiration set' do + generate_and_cache_issues_ids(count: 3) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + end + + context 'cleanup cache' do + before do + generate_and_cache_issues_ids(count: 3) + rebalance_caching.cache_current_index(123) + rebalance_caching.cache_current_project_id(456) + rebalance_caching.track_new_running_rebalance + end + + it 'removes cache keys' do + expect(check_existing_keys).to eq(4) + + rebalance_caching.cleanup_cache + + expect(check_existing_keys).to eq(0) + end + end + end + + context 'rebalancing issues in namespace' do + let_it_be(:group) { create(:group, :private) } + let_it_be(:project) { create(:project, namespace: group) } + + subject(:rebalance_caching) { described_class.new(group, group.projects) } + + it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::NAMESPACE) } + + it_behaves_like 'issues rebalance caching' + end + + context 'rebalancing issues in a project' do + let_it_be(:project) { create(:project) } + + subject(:rebalance_caching) { described_class.new(project.namespace, Project.where(id: project)) } + + it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::PROJECT) } + + it_behaves_like 'issues rebalance caching' + end + + # count - how many issue ids to generate, issue ids will start at 1 + # position_offset - if you'd want to offset generated relative_position for the issue ids, + # relative_position is generated as = issue id * 10 + position_offset + # position_direction - (1) for positive relative_positions, (-1) for negative relative_positions + def generate_and_cache_issues_ids(count:, position_offset: 0, position_direction: 1) + issues = [] + + count.times do |idx| + id = idx + 1 + issues << double(relative_position: position_direction * (id * 10 + position_offset), id: id) + end + + rebalance_caching.cache_issue_ids(issues) + end + + def check_existing_keys + index = 0 + + index += 1 if rebalance_caching.get_current_index > 0 + index += 1 if rebalance_caching.get_current_project_id.present? + index += 1 if rebalance_caching.get_cached_issue_ids(0, 100).present? + index += 1 if rebalance_caching.rebalance_in_progress? + + index + end +end diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb index b867dd533e0..3c14d91fdfd 100644 --- a/spec/lib/gitlab/pagination/keyset/order_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -538,6 +538,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do end it_behaves_like 'cursor attribute examples' + + context 'with projections' do + context 'when additional_projections is empty' do + let(:scope) { Project.select(:id, :namespace_id) } + + subject(:sql) { order.apply_cursor_conditions(scope, { id: '100' }).to_sql } + + it 'has correct projections' do + is_expected.to include('SELECT "projects"."id", "projects"."namespace_id" FROM "projects"') + end + end + + context 'when there are additional_projections' do + let(:order) do + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'created_at_field', + column_expression: Project.arel_table[:created_at], + order_expression: Project.arel_table[:created_at].desc, + order_direction: :desc, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Project.arel_table[:id].desc + ) + ]) + + order + end + + let(:scope) { Project.select(:id, :namespace_id).reorder(order) } + + subject(:sql) { order.apply_cursor_conditions(scope).to_sql } + + it 'has correct projections' do + is_expected.to include('SELECT "projects"."id", "projects"."namespace_id", "projects"."created_at" AS created_at_field FROM "projects"') + end + end + end end end end diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb index dd57cd7980e..3f39d969dbd 100644 --- a/spec/lib/marginalia_spec.rb +++ b/spec/lib/marginalia_spec.rb @@ -42,7 +42,8 @@ RSpec.describe 'Marginalia spec' do { "application" => "test", "endpoint_id" => "MarginaliaTestController#first_user", - "correlation_id" => correlation_id + "correlation_id" => correlation_id, + "db_config_name" => "main" } end @@ -51,6 +52,29 @@ RSpec.describe 'Marginalia spec' do expect(recorded.log.last).to include("#{component}:#{value}") end end + + context 'when using CI database' do + let(:component_map) do + { + "application" => "test", + "endpoint_id" => "MarginaliaTestController#first_user", + "correlation_id" => correlation_id, + "db_config_name" => "ci" + } + end + + before do |example| + skip_if_multiple_databases_not_setup + + allow(User).to receive(:connection) { Ci::CiDatabaseRecord.connection } + end + + it 'generates a query that includes the component and value' do + component_map.each do |component, value| + expect(recorded.log.last).to include("#{component}:#{value}") + end + end + end end describe 'for Sidekiq worker jobs' do @@ -79,7 +103,8 @@ RSpec.describe 'Marginalia spec' do "application" => "sidekiq", "endpoint_id" => "MarginaliaTestJob", "correlation_id" => sidekiq_job['correlation_id'], - "jid" => sidekiq_job['jid'] + "jid" => sidekiq_job['jid'], + "db_config_name" => "main" } end @@ -100,9 +125,10 @@ RSpec.describe 'Marginalia spec' do let(:component_map) do { - "application" => "sidekiq", - "endpoint_id" => "ActionMailer::MailDeliveryJob", - "jid" => delivery_job.job_id + "application" => "sidekiq", + "endpoint_id" => "ActionMailer::MailDeliveryJob", + "jid" => delivery_job.job_id, + "db_config_name" => "main" } end diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb new file mode 100644 index 00000000000..9538ef9bb4a --- /dev/null +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomEmojiPolicy do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:custom_emoji) { create(:custom_emoji, group: group) } + + let(:custom_emoji_permissions) do + [ + :create_custom_emoji, + :delete_custom_emoji + ] + end + + context 'custom emoji permissions' do + subject { described_class.new(user, custom_emoji) } + + context 'when user is' do + context 'a developer' do + before do + group.add_developer(user) + end + + it do + expect_allowed(:create_custom_emoji) + end + end + + context 'is maintainer' do + before do + group.add_maintainer(user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is owner' do + before do + group.add_owner(user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is developer and emoji creator' do + before do + group.add_developer(user) + custom_emoji.update_attribute(:creator, user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is emoji creator but not a member of the group' do + before do + custom_emoji.update_attribute(:creator, user) + end + + it do + expect_disallowed(*custom_emoji_permissions) + end + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb new file mode 100644 index 00000000000..07fd57a2cee --- /dev/null +++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Deletion of custom emoji' do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:current_user) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group, creator: user2) } + + let(:mutation) do + variables = { + id: GitlabSchema.id_from_object(custom_emoji).to_s + } + + graphql_mutation(:destroy_custom_emoji, variables) + end + + shared_examples 'does not delete custom emoji' do + it 'does not change count' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(CustomEmoji, :count) + end + end + + shared_examples 'deletes custom emoji' do + it 'changes count' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(CustomEmoji, :count).by(-1) + end + end + + context 'when the user' do + context 'has no permissions' do + it_behaves_like 'does not delete custom emoji' + end + + context 'when the user is developer and not creator of custom emoji' do + before do + group.add_developer(current_user) + end + + it_behaves_like 'does not delete custom emoji' + end + end + + context 'when user' do + context 'is maintainer' do + before do + group.add_maintainer(current_user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is owner' do + before do + group.add_owner(current_user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is developer and creator of the emoji' do + before do + group.add_developer(current_user) + custom_emoji.update_attribute(:creator, current_user) + end + + it_behaves_like 'deletes custom emoji' + end + end +end diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb index 899872859a9..f6bed0d74fb 100644 --- a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb +++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb @@ -11,6 +11,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do before do allow(cop).to receive(:in_migration?).and_return(true) + allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE + 5) end context 'when text columns are defined without a limit' do @@ -26,7 +27,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do ^^^^ #{msg} end - create_table_with_constraints :test_text_limits_create do |t| + create_table :test_text_limits_create do |t| t.integer :test_id, null: false t.text :title t.text :description @@ -61,13 +62,10 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do t.text :name end - create_table_with_constraints :test_text_limits_create do |t| + create_table :test_text_limits_create do |t| t.integer :test_id, null: false - t.text :title - t.text :description - - t.text_limit :title, 100 - t.text_limit :description, 255 + t.text :title, limit: 100 + t.text :description, limit: 255 end add_column :test_text_limits, :email, :text @@ -82,6 +80,30 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end RUBY end + + context 'for migrations before 2021_09_10_00_00_00' do + it 'when limit: attribute is used (which is not supported yet for this version): registers an offense' do + allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE - 5) + + expect_offense(<<~RUBY) + class TestTextLimits < ActiveRecord::Migration[6.0] + def up + create_table :test_text_limit_attribute do |t| + t.integer :test_id, null: false + t.text :name, limit: 100 + ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` + end + + create_table_with_constraints :test_text_limit_attribute do |t| + t.integer :test_id, null: false + t.text :name, limit: 100 + ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` + end + end + end + RUBY + end + end end context 'when text array columns are defined without a limit' do diff --git a/spec/services/issue_rebalancing_service_spec.rb b/spec/services/issue_rebalancing_service_spec.rb deleted file mode 100644 index 76ccb6d89ea..00000000000 --- a/spec/services/issue_rebalancing_service_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe IssueRebalancingService do - let_it_be(:project, reload: true) { create(:project) } - let_it_be(:user) { project.creator } - let_it_be(:start) { RelativePositioning::START_POSITION } - let_it_be(:max_pos) { RelativePositioning::MAX_POSITION } - let_it_be(:min_pos) { RelativePositioning::MIN_POSITION } - let_it_be(:clump_size) { 300 } - - let_it_be(:unclumped, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: start + (1024 * i)) - end - end - - let_it_be(:end_clump, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: max_pos - i) - end - end - - let_it_be(:start_clump, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: min_pos + i) - end - end - - before do - stub_feature_flags(issue_rebalancing_with_retry: false) - end - - def issues_in_position_order - project.reload.issues.reorder(relative_position: :asc).to_a - end - - shared_examples 'IssueRebalancingService shared examples' do - it 'rebalances a set of issues with clumps at the end and start' do - all_issues = start_clump + unclumped + end_clump.reverse - service = described_class.new(Project.id_in([project.id])) - - expect { service.execute }.not_to change { issues_in_position_order.map(&:id) } - - all_issues.each(&:reset) - - gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b| - b.relative_position - a.relative_position - end - - expect(gaps).to all(be > RelativePositioning::MIN_GAP) - expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999) - expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999) - end - - it 'is idempotent' do - service = described_class.new(Project.id_in(project)) - - expect do - service.execute - service.execute - end.not_to change { issues_in_position_order.map(&:id) } - end - - it 'does nothing if the feature flag is disabled' do - stub_feature_flags(rebalance_issues: false) - issue = project.issues.first - issue.project - issue.project.group - old_pos = issue.relative_position - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.not_to exceed_query_limit(0) - expect(old_pos).to eq(issue.reload.relative_position) - end - - it 'acts if the flag is enabled for the root namespace' do - issue = create(:issue, project: project, author: user, relative_position: max_pos) - stub_feature_flags(rebalance_issues: project.root_namespace) - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.to change { issue.reload.relative_position } - end - - it 'acts if the flag is enabled for the group' do - issue = create(:issue, project: project, author: user, relative_position: max_pos) - project.update!(group: create(:group)) - stub_feature_flags(rebalance_issues: issue.project.group) - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.to change { issue.reload.relative_position } - end - - it 'aborts if there are too many issues' do - base = double(count: 10_001) - - allow(Issue).to receive(:in_projects).and_return(base) - - expect { described_class.new(Project.id_in(project)).execute }.to raise_error(described_class::TooManyIssues) - end - end - - shared_examples 'rebalancing is retried on statement timeout exceptions' do - subject { described_class.new(Project.id_in(project)) } - - it 'retries update statement' do - call_count = 0 - allow(subject).to receive(:run_update_query) do - call_count += 1 - if call_count < 13 - raise(ActiveRecord::QueryCanceled) - else - call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch - true - end - end - - # call math: - # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised. - # We raise ActiveRecord::StatementTimeout exception for 13 calls: - # 1. 100 => 3 calls - # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout - # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout - # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout - # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully - # - # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements - # - # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261 - expect(subject).to receive(:update_positions).exactly(261).times.and_call_original - - subject.execute - end - end - - context 'when issue_rebalancing_optimization feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_optimization: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - - context 'when issue_rebalancing_with_retry feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_with_retry: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - it_behaves_like 'rebalancing is retried on statement timeout exceptions' - end - end - - context 'when issue_rebalancing_optimization feature flag is off' do - before do - stub_feature_flags(issue_rebalancing_optimization: false) - end - - it_behaves_like 'IssueRebalancingService shared examples' - - context 'when issue_rebalancing_with_retry feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_with_retry: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - it_behaves_like 'rebalancing is retried on statement timeout exceptions' - end - end -end diff --git a/spec/services/issues/relative_position_rebalancing_service_spec.rb b/spec/services/issues/relative_position_rebalancing_service_spec.rb new file mode 100644 index 00000000000..d5d81770817 --- /dev/null +++ b/spec/services/issues/relative_position_rebalancing_service_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state do + let_it_be(:project, reload: true) { create(:project) } + let_it_be(:user) { project.creator } + let_it_be(:start) { RelativePositioning::START_POSITION } + let_it_be(:max_pos) { RelativePositioning::MAX_POSITION } + let_it_be(:min_pos) { RelativePositioning::MIN_POSITION } + let_it_be(:clump_size) { 300 } + + let_it_be(:unclumped, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: start + (1024 * i)) + end + end + + let_it_be(:end_clump, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: max_pos - i) + end + end + + let_it_be(:start_clump, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: min_pos + i) + end + end + + before do + stub_feature_flags(issue_rebalancing_with_retry: false) + end + + def issues_in_position_order + project.reload.issues.reorder(relative_position: :asc).to_a + end + + subject(:service) { described_class.new(Project.id_in(project)) } + + context 'execute' do + it 're-balances a set of issues with clumps at the end and start' do + all_issues = start_clump + unclumped + end_clump.reverse + + expect { service.execute }.not_to change { issues_in_position_order.map(&:id) } + + all_issues.each(&:reset) + + gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b| + b.relative_position - a.relative_position + end + + expect(gaps).to all(be > RelativePositioning::MIN_GAP) + expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999) + expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999) + expect(project.root_namespace.issue_repositioning_disabled?).to be false + end + + it 'is idempotent' do + expect do + service.execute + service.execute + end.not_to change { issues_in_position_order.map(&:id) } + end + + it 'does nothing if the feature flag is disabled' do + stub_feature_flags(rebalance_issues: false) + issue = project.issues.first + issue.project + issue.project.group + old_pos = issue.relative_position + + # fetching root namespace in the initializer triggers 2 queries: + # for fetching a random project from collection and fetching the root namespace. + expect { service.execute }.not_to exceed_query_limit(2) + expect(old_pos).to eq(issue.reload.relative_position) + end + + it 'acts if the flag is enabled for the root namespace' do + issue = create(:issue, project: project, author: user, relative_position: max_pos) + stub_feature_flags(rebalance_issues: project.root_namespace) + + expect { service.execute }.to change { issue.reload.relative_position } + end + + it 'acts if the flag is enabled for the group' do + issue = create(:issue, project: project, author: user, relative_position: max_pos) + project.update!(group: create(:group)) + stub_feature_flags(rebalance_issues: issue.project.group) + + expect { service.execute }.to change { issue.reload.relative_position } + end + + it 'aborts if there are too many rebalances running' do + caching = service.send(:caching) + allow(caching).to receive(:rebalance_in_progress?).and_return(false) + allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10) + allow(service).to receive(:caching).and_return(caching) + + expect { service.execute }.to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect(project.root_namespace.issue_repositioning_disabled?).to be false + end + + it 'resumes a started rebalance even if there are already too many rebalances running' do + Gitlab::Redis::SharedState.with do |redis| + redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "#{::Gitlab::Issues::Rebalancing::State::PROJECT}/#{project.id}") + redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "1/100") + end + + caching = service.send(:caching) + allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10) + allow(service).to receive(:caching).and_return(caching) + + expect { service.execute }.not_to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + end + + context 're-balancing is retried on statement timeout exceptions' do + subject { service } + + it 'retries update statement' do + call_count = 0 + allow(subject).to receive(:run_update_query) do + call_count += 1 + if call_count < 13 + raise(ActiveRecord::QueryCanceled) + else + call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch + true + end + end + + # call math: + # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised. + # We raise ActiveRecord::StatementTimeout exception for 13 calls: + # 1. 100 => 3 calls + # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout + # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout + # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout + # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully + # + # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements + # + # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261 + expect(subject).to receive(:update_positions).exactly(261).times.and_call_original + + subject.execute + end + end + + context 'when resuming a stopped rebalance' do + before do + service.send(:preload_issue_ids) + expect(service.send(:caching).get_cached_issue_ids(0, 300)).not_to be_empty + # simulate we already rebalanced half the issues + index = clump_size * 3 / 2 + 1 + service.send(:caching).cache_current_index(index) + end + + it 'rebalances the other half of issues' do + expect(subject).to receive(:update_positions_with_retry).exactly(5).and_call_original + + subject.execute + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f4d5dccc98d..a6d767acf3c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -164,6 +164,7 @@ RSpec.configure do |config| config.include NextInstanceOf config.include TestEnv config.include FileReadHelpers + config.include Database::MultipleDatabases config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :view config.include Devise::Test::IntegrationHelpers, type: :feature diff --git a/spec/support/database/multiple_databases.rb b/spec/support/database/multiple_databases.rb new file mode 100644 index 00000000000..8ce642a682c --- /dev/null +++ b/spec/support/database/multiple_databases.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Database + module MultipleDatabases + def skip_if_multiple_databases_not_setup + skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) + end + end +end diff --git a/spec/support/helpers/bare_repo_operations.rb b/spec/support/helpers/bare_repo_operations.rb index 98fa13db6c2..cd2dcecd1b6 100644 --- a/spec/support/helpers/bare_repo_operations.rb +++ b/spec/support/helpers/bare_repo_operations.rb @@ -17,7 +17,6 @@ class BareRepoOperations commit_id[0] end - # Based on https://stackoverflow.com/a/25556917/1856239 def commit_file(file, dst_path, branch = 'master') head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || Gitlab::Git::EMPTY_TREE_ID diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb index b6e9429d78e..cba42a1577e 100644 --- a/spec/workers/issue_rebalancing_worker_spec.rb +++ b/spec/workers/issue_rebalancing_worker_spec.rb @@ -8,41 +8,29 @@ RSpec.describe IssueRebalancingWorker do let_it_be(:project) { create(:project, group: group) } let_it_be(:issue) { create(:issue, project: project) } - context 'when block_issue_repositioning is enabled' do - before do - stub_feature_flags(block_issue_repositioning: group) - end - - it 'does not run an instance of IssueRebalancingService' do - expect(IssueRebalancingService).not_to receive(:new) - - described_class.new.perform(nil, issue.project_id) - end - end - shared_examples 'running the worker' do - it 'runs an instance of IssueRebalancingService' do + it 'runs an instance of Issues::RelativePositionRebalancingService' do service = double(execute: nil) service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) - expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service) + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) described_class.new.perform(*arguments) end - it 'anticipates there being too many issues' do + it 'anticipates there being too many concurent rebalances' do service = double service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) - allow(service).to receive(:execute).and_raise(IssueRebalancingService::TooManyIssues) - expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service) - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(IssueRebalancingService::TooManyIssues, include(project_id: arguments.second, root_namespace_id: arguments.third)) + allow(service).to receive(:execute).and_raise(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances, include(project_id: arguments.second, root_namespace_id: arguments.third)) described_class.new.perform(*arguments) end it 'takes no action if the value is nil' do - expect(IssueRebalancingService).not_to receive(:new) + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) expect(Gitlab::ErrorTracking).not_to receive(:log_exception) described_class.new.perform # all arguments are nil @@ -52,7 +40,7 @@ RSpec.describe IssueRebalancingWorker do shared_examples 'safely handles non-existent ids' do it 'anticipates the inability to find the issue' do expect(Gitlab::ErrorTracking).to receive(:log_exception).with(ArgumentError, include(project_id: arguments.second, root_namespace_id: arguments.third)) - expect(IssueRebalancingService).not_to receive(:new) + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) described_class.new.perform(*arguments) end