diff options
33 files changed, 435 insertions, 338 deletions
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index 0c69072f06a..51fae60b6b7 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -28,11 +28,6 @@ export default { }; }, methods: { - fnCurrentTokenValue(data) { - // By default, values are transformed with `toLowerCase` - // however, runner tags are case sensitive. - return data; - }, getTagsOptions(search) { // TODO This should be implemented via a GraphQL API // The API should @@ -72,7 +67,6 @@ export default { :config="config" :suggestions-loading="loading" :suggestions="tags" - :fn-current-token-value="fnCurrentTokenValue" :recent-suggestions-storage-key="config.recentTokenValuesStorageKey" @fetch-suggestions="fetchTags" v-on="$listeners" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index a25a19a006c..ae5d3965de1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -31,19 +31,25 @@ export default { data() { return { authors: this.config.initialAuthors || [], - defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], - preloadedAuthors: this.config.preloadedAuthors || [], loading: false, }; }, + computed: { + defaultAuthors() { + return this.config.defaultAuthors || [DEFAULT_LABEL_ANY]; + }, + preloadedAuthors() { + return this.config.preloadedAuthors || []; + }, + }, methods: { - getActiveAuthor(authors, currentValue) { - return authors.find((author) => author.username.toLowerCase() === currentValue); + getActiveAuthor(authors, data) { + return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); }, getAvatarUrl(author) { return author.avatarUrl || author.avatar_url; }, - fetchAuthorBySearchTerm(searchTerm) { + fetchAuthors(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) @@ -76,11 +82,11 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="authors" - :fn-active-token-value="getActiveAuthor" + :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchAuthorBySearchTerm" + @fetch-suggestions="fetchAuthors" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -91,7 +97,7 @@ export default { shape="circle" class="gl-mr-2" /> - <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 102c513c145..172b5c402f6 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -42,12 +42,10 @@ export default { required: false, default: () => [], }, - fnActiveTokenValue: { + getActiveTokenValue: { type: Function, required: false, - default: (suggestions, currentTokenValue) => { - return suggestions.find(({ value }) => value === currentTokenValue); - }, + default: (suggestions, data) => suggestions.find(({ value }) => value === data), }, defaultSuggestions: { type: Array, @@ -69,11 +67,6 @@ export default { required: false, default: 'id', }, - fnCurrentTokenValue: { - type: Function, - required: false, - default: null, - }, }, data() { return { @@ -81,7 +74,6 @@ export default { recentSuggestions: this.recentSuggestionsStorageKey ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) : [], - loading: false, }; }, computed: { @@ -94,14 +86,8 @@ export default { preloadedTokenIds() { return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, - currentTokenValue() { - if (this.fnCurrentTokenValue) { - return this.fnCurrentTokenValue(this.value.data); - } - return this.value.data.toLowerCase(); - }, activeTokenValue() { - return this.fnActiveTokenValue(this.suggestions, this.currentTokenValue); + return this.getActiveTokenValue(this.suggestions, this.value.data); }, /** * Return all the suggestions when searchKey is present diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 4d08f81fee9..ae514c47068 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -33,14 +33,18 @@ export default { data() { return { labels: this.config.initialLabels || [], - defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, loading: false, }; }, + computed: { + defaultLabels() { + return this.config.defaultLabels || DEFAULT_LABELS; + }, + }, methods: { - getActiveLabel(labels, currentValue) { + getActiveLabel(labels, data) { return labels.find( - (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue), + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(data).toLowerCase(), ); }, /** @@ -68,7 +72,7 @@ export default { } return {}; }, - fetchLabelBySearchTerm(searchTerm) { + fetchLabels(searchTerm) { this.loading = true; this.config .fetchLabels(searchTerm) @@ -98,10 +102,10 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="labels" - :fn-active-token-value="getActiveLabel" + :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchLabelBySearchTerm" + @fetch-suggestions="fetchLabels" v-on="$listeners" > <template diff --git a/app/graphql/types/merge_request_sort_enum.rb b/app/graphql/types/merge_request_sort_enum.rb index 92a71998d91..d75eae6abc4 100644 --- a/app/graphql/types/merge_request_sort_enum.rb +++ b/app/graphql/types/merge_request_sort_enum.rb @@ -7,5 +7,7 @@ module Types value 'MERGED_AT_ASC', 'Merge time by ascending order.', value: :merged_at_asc value 'MERGED_AT_DESC', 'Merge time by descending order.', value: :merged_at_desc + value 'CLOSED_AT_ASC', 'Closed time by ascending order.', value: :closed_at_asc + value 'CLOSED_AT_DESC', 'Closed time by descending order.', value: :closed_at_desc end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index da32dfb0b9b..7fa85d143f7 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -3,6 +3,7 @@ module SortingHelper include SortingTitlesValuesHelper + # rubocop: disable Metrics/AbcSize def sort_options_hash { sort_value_created_date => sort_title_created_date, @@ -29,6 +30,9 @@ module SortingHelper sort_value_merged_date => sort_title_merged_date, sort_value_merged_recently => sort_title_merged_recently, sort_value_merged_earlier => sort_title_merged_earlier, + sort_value_closed_date => sort_title_closed_date, + sort_value_closed_recently => sort_title_closed_recently, + sort_value_closed_earlier => sort_title_closed_earlier, sort_value_upvotes => sort_title_upvotes, sort_value_contacted_date => sort_title_contacted_date, sort_value_relative_position => sort_title_relative_position, @@ -36,6 +40,7 @@ module SortingHelper sort_value_expire_date => sort_title_expire_date } end + # rubocop: enable Metrics/AbcSize def projects_sort_options_hash use_old_sorting = Feature.disabled?(:project_list_filter_bar) || current_controller?('admin/projects') @@ -182,6 +187,7 @@ module SortingHelper sort_value_milestone_later => sort_value_milestone, sort_value_due_date_later => sort_value_due_date, sort_value_merged_recently => sort_value_merged_date, + sort_value_closed_recently => sort_value_closed_date, sort_value_least_popular => sort_value_popularity } end @@ -196,6 +202,8 @@ module SortingHelper sort_value_due_date_soon => sort_value_due_date_later, sort_value_merged_date => sort_value_merged_recently, sort_value_merged_earlier => sort_value_merged_recently, + sort_value_closed_date => sort_value_closed_recently, + sort_value_closed_earlier => sort_value_closed_recently, sort_value_popularity => sort_value_least_popular, sort_value_most_popular => sort_value_least_popular }.merge(issuable_sort_option_overrides) @@ -216,7 +224,7 @@ module SortingHelper def sort_direction_icon(sort_value) case sort_value - when sort_value_milestone, sort_value_due_date, sort_value_merged_date, /_asc\z/ + when sort_value_milestone, sort_value_due_date, sort_value_merged_date, sort_value_closed_date, /_asc\z/ 'sort-lowest' else 'sort-highest' diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb index 9b839f4e9bc..f4117d690f3 100644 --- a/app/helpers/sorting_titles_values_helper.rb +++ b/app/helpers/sorting_titles_values_helper.rb @@ -38,6 +38,18 @@ module SortingTitlesValuesHelper s_('SortOptions|Merged earlier') end + def sort_title_closed_date + s_('SortOptions|Closed date') + end + + def sort_title_closed_recently + s_('SortOptions|Closed recently') + end + + def sort_title_closed_earlier + s_('SortOptions|Closed earlier') + end + def sort_title_largest_group s_('SortOptions|Largest group') end @@ -199,6 +211,18 @@ module SortingTitlesValuesHelper 'merged_at_asc' end + def sort_value_closed_date + 'closed_at' + end + + def sort_value_closed_recently + 'closed_at_desc' + end + + def sort_value_closed_earlier + 'closed_at_asc' + end + def sort_value_largest_group 'storage_size_desc' end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index d5e2e63402f..9b3f517cdee 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -374,6 +374,8 @@ module Issuable grouping_columns << milestone_table[:due_date] elsif %w(merged_at_desc merged_at_asc).include?(sort) grouping_columns << MergeRequest::Metrics.arel_table[:merged_at] + elsif %w(closed_at_desc closed_at_asc).include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:closed_at] end grouping_columns diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7ca83d1d68c..a090ac87cc9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -329,16 +329,16 @@ class MergeRequest < ApplicationRecord where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } - scope :order_merged_at, ->(direction) do + scope :order_by_metric, ->(metric, direction) do reverse_direction = { 'ASC' => 'DESC', 'DESC' => 'ASC' } reversed_direction = reverse_direction[direction] || raise("Unknown sort direction was given: #{direction}") order = Gitlab::Pagination::Keyset::Order.build([ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'merge_request_metrics_merged_at', - column_expression: MergeRequest::Metrics.arel_table[:merged_at], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', reversed_direction), + attribute_name: "merge_request_metrics_#{metric}", + column_expression: MergeRequest::Metrics.arel_table[metric], + order_expression: Gitlab::Database.nulls_last_order("merge_request_metrics.#{metric}", direction), + reversed_order_expression: Gitlab::Database.nulls_first_order("merge_request_metrics.#{metric}", reversed_direction), order_direction: direction, nullable: :nulls_last, distinct: false, @@ -353,8 +353,10 @@ class MergeRequest < ApplicationRecord order.apply_cursor_conditions(join_metrics).order(order) end - scope :order_merged_at_asc, -> { order_merged_at('ASC') } - scope :order_merged_at_desc, -> { order_merged_at('DESC') } + scope :order_merged_at_asc, -> { order_by_metric(:merged_at, 'ASC') } + scope :order_merged_at_desc, -> { order_by_metric(:merged_at, 'DESC') } + scope :order_closed_at_asc, -> { order_by_metric(:latest_closed_at, 'ASC') } + scope :order_closed_at_desc, -> { order_by_metric(:latest_closed_at, 'DESC') } scope :preload_source_project, -> { preload(:source_project) } scope :preload_target_project, -> { preload(:target_project) } scope :preload_routables, -> do @@ -452,7 +454,9 @@ class MergeRequest < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'merged_at', 'merged_at_asc' then order_merged_at_asc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc when 'merged_at_desc' then order_merged_at_desc + when 'closed_at_desc' then order_closed_at_desc else super end diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index caf271e9ee9..f5bf010e4db 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -19,6 +19,7 @@ = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) = sortable_item(sort_title_merged_date, page_filter_path(sort: sort_value_merged_date), sort_title) if viewing_merge_requests + = sortable_item(sort_title_closed_date, page_filter_path(sort: sort_value_closed_date), sort_title) if viewing_merge_requests = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title) = issuable_sort_direction_button(sort_value) diff --git a/doc/administration/gitaly/faq.md b/doc/administration/gitaly/faq.md index 2712432f6dc..c7ecaa020e0 100644 --- a/doc/administration/gitaly/faq.md +++ b/doc/administration/gitaly/faq.md @@ -35,7 +35,7 @@ For more information, see: ## Are there instructions for migrating to Gitaly Cluster? -Yes! For more information, see [Migrate to Gitaly Cluster](praefect.md#migrate-to-gitaly-cluster). +Yes! For more information, see [Migrate to Gitaly Cluster](index.md#migrate-to-gitaly-cluster). ## What are some repository storage recommendations? diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 86c0ce392f2..bca83e903ac 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -306,6 +306,18 @@ For configuration information, see [Configure replication factor](praefect.md#co For more information on configuring Gitaly Cluster, see [Configure Gitaly Cluster](praefect.md). +### Migrate to Gitaly Cluster + +Whether migrating to Gitaly Cluster because of [NFS support deprecation](index.md#nfs-deprecation-notice) +or to move from single Gitaly nodes, the basic process involves: + +1. Create the required storage. Refer to + [repository storage recommendations](faq.md#what-are-some-repository-storage-recommendations). +1. Create and configure [Gitaly Cluster](praefect.md). +1. [Move the repositories](../operations/moving_repositories.md#move-repositories). To migrate to + Gitaly Cluster, existing repositories stored outside Gitaly Cluster must be moved. There is no + automatic migration but the moves can be scheduled with the GitLab API. + ## Monitor Gitaly and Gitaly Cluster You can use the available logs and [Prometheus metrics](../monitoring/prometheus/index.md) to @@ -389,7 +401,7 @@ The following are useful queries for monitoring Gitaly: {enforced="true",status="ok"} 4424.985419441742 ``` - There may also be other numbers with rate 0, but you only need to take note of the non-zero numbers. + There may also be other numbers with rate 0, but you only have to take note of the non-zero numbers. The only non-zero number should have `enforced="true",status="ok"`. If you have other non-zero numbers, something is wrong in your configuration. @@ -560,7 +572,7 @@ Additional information: GitLab recommends: - Creating a [Gitaly Cluster](#gitaly-cluster) as soon as possible. -- [Moving your repositories](praefect.md#migrate-to-gitaly-cluster) from NFS-based storage to Gitaly +- [Moving your repositories](#migrate-to-gitaly-cluster) from NFS-based storage to Gitaly Cluster. We welcome your feedback on this process. You can: diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 6a794dba4f9..4af7f1a58a5 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -432,7 +432,7 @@ On the **Praefect** node: WARNING: If you have data on an already existing storage called `default`, you should configure the virtual storage with another name and - [migrate the data to the Gitaly Cluster storage](#migrate-to-gitaly-cluster) + [migrate the data to the Gitaly Cluster storage](index.md#migrate-to-gitaly-cluster) afterwards. Replace `PRAEFECT_INTERNAL_TOKEN` with a strong secret, which is used by @@ -896,7 +896,7 @@ Particular attention should be shown to: WARNING: If you have existing data stored on the default Gitaly storage, - you should [migrate the data your Gitaly Cluster storage](#migrate-to-gitaly-cluster) + you should [migrate the data your Gitaly Cluster storage](index.md#migrate-to-gitaly-cluster) first. ```ruby @@ -1574,132 +1574,3 @@ sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.t - Replace the placeholder `<virtual-storage>` with the virtual storage containing the Gitaly node storage to be checked. - Replace the placeholder `<up-to-date-storage>` with the Gitaly storage name containing up to date repositories. - Replace the placeholder `<outdated-storage>` with the Gitaly storage name containing outdated repositories. - -## Migrate to Gitaly Cluster - -Whether migrating to Gitaly Cluster because of [NFS support deprecation](index.md#nfs-deprecation-notice) -or to move from single Gitaly nodes, the basic process involves: - -1. Create the required storage. -1. Create and configure Gitaly Cluster. -1. [Move the repositories](#move-repositories). - -When creating the storage, see some -[repository storage recommendations](faq.md#what-are-some-repository-storage-recommendations). - -### Move Repositories - -WARNING: -To move repositories into a Gitaly Cluster in GitLab versions 13.12 to 14.1, you must -[enable the `gitaly_replicate_repository_direct_fetch` feature flag](../feature_flags.md). - -To migrate to Gitaly Cluster, existing repositories stored outside Gitaly Cluster must be -moved. There is no automatic migration but the moves can be scheduled with the GitLab API. - -GitLab repositories can be associated with projects, groups, and snippets. Each of these types -have a separate API to schedule the respective repositories to move. To move all repositories -on a GitLab instance, each of these types must be scheduled to move for each storage. - -Each repository is made read-only for the duration of the move. The repository is not writable -until the move has completed. - -After creating and configuring Gitaly Cluster: - -1. Ensure all storages are accessible to the GitLab instance. In this example, these are - `<original_storage_name>` and `<cluster_storage_name>`. -1. [Configure repository storage weights](../repository_storage_paths.md#configure-where-new-repositories-are-stored) - so that the Gitaly Cluster receives all new projects. This stops new projects from being created - on existing Gitaly nodes while the migration is in progress. -1. Schedule repository moves for: - - [Projects](#bulk-schedule-project-moves). - - [Snippets](#bulk-schedule-snippet-moves). - - [Groups](#bulk-schedule-group-moves). **(PREMIUM SELF)** - -#### Bulk schedule project moves - -1. [Schedule repository storage moves for all projects on a storage shard](../../api/project_repository_storage_moves.md#schedule-repository-storage-moves-for-all-projects-on-a-storage-shard) using the API. For example: - - ```shell - curl --request POST --header "Private-Token: <your_access_token>" \ - --header "Content-Type: application/json" \ - --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ - "https://gitlab.example.com/api/v4/project_repository_storage_moves" - ``` - -1. [Query the most recent repository moves](../../api/project_repository_storage_moves.md#retrieve-all-project-repository-storage-moves) - using the API. The query indicates either: - - The moves have completed successfully. The `state` field is `finished`. - - The moves are in progress. Re-query the repository move until it completes successfully. - - The moves have failed. Most failures are temporary and are solved by rescheduling the move. - -1. After the moves are complete, [query projects](../../api/projects.md#list-all-projects) - using the API to confirm that all projects have moved. No projects should be returned - with `repository_storage` field set to the old storage. - - ```shell - curl --header "Private-Token: <your_access_token>" --header "Content-Type: application/json" \ - "https://gitlab.example.com/api/v4/projects?repository_storage=<original_storage_name>" - ``` - - Alternatively use [the rails console](../operations/rails_console.md) to - confirm that all projects have moved. Run the following in the rails console: - - ```ruby - ProjectRepository.for_repository_storage('<original_storage_name>') - ``` - -1. Repeat for each storage as required. - -#### Bulk schedule snippet moves - -1. [Schedule repository storage moves for all snippets on a storage shard](../../api/snippet_repository_storage_moves.md#schedule-repository-storage-moves-for-all-snippets-on-a-storage-shard) using the API. For example: - - ```shell - curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ - --header "Content-Type: application/json" \ - --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ - "https://gitlab.example.com/api/v4/snippet_repository_storage_moves" - ``` - -1. [Query the most recent repository moves](../../api/snippet_repository_storage_moves.md#retrieve-all-snippet-repository-storage-moves) - using the API. The query indicates either: - - The moves have completed successfully. The `state` field is `finished`. - - The moves are in progress. Re-query the repository move until it completes successfully. - - The moves have failed. Most failures are temporary and are solved by rescheduling the move. - -1. After the moves are complete, use [the rails console](../operations/rails_console.md) to - confirm that all snippets have moved. No snippets should be returned for the original - storage. Run the following in the rails console: - - ```ruby - SnippetRepository.for_repository_storage('<original_storage_name>') - ``` - -1. Repeat for each storage as required. - -#### Bulk schedule group moves **(PREMIUM SELF)** - -1. [Schedule repository storage moves for all groups on a storage shard](../../api/group_repository_storage_moves.md#schedule-repository-storage-moves-for-all-groups-on-a-storage-shard) using the API. - - ```shell - curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ - --header "Content-Type: application/json" \ - --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ - "https://gitlab.example.com/api/v4/group_repository_storage_moves" - ``` - -1. [Query the most recent repository moves](../../api/group_repository_storage_moves.md#retrieve-all-group-repository-storage-moves) - using the API. The query indicates either: - - The moves have completed successfully. The `state` field is `finished`. - - The moves are in progress. Re-query the repository move until it completes successfully. - - The moves have failed. Most failures are temporary and are solved by rescheduling the move. - -1. After the moves are complete, use [the rails console](../operations/rails_console.md) to - confirm that all groups have moved. No groups should be returned for the original - storage. Run the following in the rails console: - - ```ruby - GroupWikiRepository.for_repository_storage('<original_storage_name>') - ``` - -1. Repeat for each storage as required. diff --git a/doc/administration/operations/moving_repositories.md b/doc/administration/operations/moving_repositories.md index fba20da9aea..765cf64e735 100644 --- a/doc/administration/operations/moving_repositories.md +++ b/doc/administration/operations/moving_repositories.md @@ -7,8 +7,7 @@ type: reference # Moving repositories managed by GitLab **(FREE SELF)** -Sometimes you need to move all repositories managed by GitLab to -another file system or another server. +You can move all repositories managed by GitLab to another file system or another server. ## Moving data within a GitLab instance @@ -28,7 +27,128 @@ For more information, see: querying and scheduling snippet repository moves. - [The API documentation](../../api/group_repository_storage_moves.md) details the endpoints for querying and scheduling group repository moves **(PREMIUM SELF)**. -- [Migrate to Gitaly Cluster](../gitaly/praefect.md#migrate-to-gitaly-cluster). +- [Migrate to Gitaly Cluster](../gitaly/index.md#migrate-to-gitaly-cluster). + +### Move Repositories + +GitLab repositories can be associated with projects, groups, and snippets. Each of these types +have a separate API to schedule the respective repositories to move. To move all repositories +on a GitLab instance, each of these types must be scheduled to move for each storage. + +WARNING: +To move repositories into a [Gitaly Cluster](../gitaly/index.md#gitaly-cluster) in GitLab versions +13.12 to 14.1, you must [enable the `gitaly_replicate_repository_direct_fetch` feature flag](../feature_flags.md). + +Each repository is made read-only for the duration of the move. The repository is not writable +until the move has completed. + +To move repositories: + +1. Ensure all storages are accessible to the GitLab instance. In this example, these are + `<original_storage_name>` and `<cluster_storage_name>`. +1. [Configure repository storage weights](../repository_storage_paths.md#configure-where-new-repositories-are-stored) + so that the new storages receives all new projects. This stops new projects from being created + on existing storages while the migration is in progress. +1. Schedule repository moves for: + - [Projects](#bulk-schedule-project-moves). + - [Snippets](#bulk-schedule-snippet-moves). + - [Groups](#bulk-schedule-group-moves). **(PREMIUM SELF)** + +### Bulk schedule project moves + +Use the API to schedule project moves: + +1. [Schedule repository storage moves for all projects on a storage shard](../../api/project_repository_storage_moves.md#schedule-repository-storage-moves-for-all-projects-on-a-storage-shard) + using the API. For example: + + ```shell + curl --request POST --header "Private-Token: <your_access_token>" \ + --header "Content-Type: application/json" \ + --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ + "https://gitlab.example.com/api/v4/project_repository_storage_moves" + ``` + +1. [Query the most recent repository moves](../../api/project_repository_storage_moves.md#retrieve-all-project-repository-storage-moves) + using the API. The response indicates either: + - The moves have completed successfully. The `state` field is `finished`. + - The moves are in progress. Re-query the repository move until it completes successfully. + - The moves have failed. Most failures are temporary and are solved by rescheduling the move. + +1. After the moves are complete, use the API to [query projects](../../api/projects.md#list-all-projects) and confirm that all projects have moved. None of the projects should be returned with the + `repository_storage` field set to the old storage. For example: + + ```shell + curl --header "Private-Token: <your_access_token>" --header "Content-Type: application/json" \ + "https://gitlab.example.com/api/v4/projects?repository_storage=<original_storage_name>" + ``` + + Alternatively use [the rails console](../operations/rails_console.md) to confirm that all + projects have moved. Run the following in the rails console: + + ```ruby + ProjectRepository.for_repository_storage('<original_storage_name>') + ``` + +1. Repeat for each storage as required. + +### Bulk schedule snippet moves + +Use the API to schedule snippet moves: + +1. [Schedule repository storage moves for all snippets on a storage shard](../../api/snippet_repository_storage_moves.md#schedule-repository-storage-moves-for-all-snippets-on-a-storage-shard). For example: + + ```shell + curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ + --header "Content-Type: application/json" \ + --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ + "https://gitlab.example.com/api/v4/snippet_repository_storage_moves" + ``` + +1. [Query the most recent repository moves](../../api/snippet_repository_storage_moves.md#retrieve-all-snippet-repository-storage-moves) +The response indicates either: + - The moves have completed successfully. The `state` field is `finished`. + - The moves are in progress. Re-query the repository move until it completes successfully. + - The moves have failed. Most failures are temporary and are solved by rescheduling the move. + +1. After the moves are complete, use [the rails console](../operations/rails_console.md) to confirm + that all snippets have moved. No snippets should be returned for the original storage. Run the + following in the rails console: + + ```ruby + SnippetRepository.for_repository_storage('<original_storage_name>') + ``` + +1. Repeat for each storage as required. + +### Bulk schedule group moves **(PREMIUM SELF)** + +Use the API to schedule group moves: + +1. [Schedule repository storage moves for all groups on a storage shard](../../api/group_repository_storage_moves.md#schedule-repository-storage-moves-for-all-groups-on-a-storage-shard) +. For example: + + ```shell + curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ + --header "Content-Type: application/json" \ + --data '{"source_storage_name":"<original_storage_name>","destination_storage_name":"<cluster_storage_name>"}' \ + "https://gitlab.example.com/api/v4/group_repository_storage_moves" + ``` + +1. [Query the most recent repository moves](../../api/group_repository_storage_moves.md#retrieve-all-group-repository-storage-moves) +. The response indicates either: + - The moves have completed successfully. The `state` field is `finished`. + - The moves are in progress. Re-query the repository move until it completes successfully. + - The moves have failed. Most failures are temporary and are solved by rescheduling the move. + +1. After the moves are complete, use [the rails console](../operations/rails_console.md) to confirm + that all groups have moved. No groups should be returned for the original storage. Run the + following in the rails console: + + ```ruby + GroupWikiRepository.for_repository_storage('<original_storage_name>') + ``` + +1. Repeat for each storage as required. ## Migrating to another GitLab instance diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md index 68f351e737a..be1a3a56e4f 100644 --- a/doc/administration/repository_storage_paths.md +++ b/doc/administration/repository_storage_paths.md @@ -156,4 +156,4 @@ paths, the more often it is chosen. That is, ## Move repositories To move a repository to a different repository storage (for example, from `default` to `storage2`), use the -same process as [migrating to Gitaly Cluster](gitaly/praefect.md#migrate-to-gitaly-cluster). +same process as [migrating to Gitaly Cluster](gitaly/index.md#migrate-to-gitaly-cluster). diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3f9fd5c1d34..8fb90ab6960 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15251,6 +15251,8 @@ Values for sorting merge requests. | Value | Description | | ----- | ----------- | +| <a id="mergerequestsortclosed_at_asc"></a>`CLOSED_AT_ASC` | Closed time by ascending order. | +| <a id="mergerequestsortclosed_at_desc"></a>`CLOSED_AT_DESC` | Closed time by descending order. | | <a id="mergerequestsortcreated_asc"></a>`CREATED_ASC` | Created at ascending order. | | <a id="mergerequestsortcreated_desc"></a>`CREATED_DESC` | Created at descending order. | | <a id="mergerequestsortlabel_priority_asc"></a>`LABEL_PRIORITY_ASC` | Label priority by ascending order. | diff --git a/doc/api/group_repository_storage_moves.md b/doc/api/group_repository_storage_moves.md index a54f50da46b..a893bffb1f5 100644 --- a/doc/api/group_repository_storage_moves.md +++ b/doc/api/group_repository_storage_moves.md @@ -10,7 +10,7 @@ type: reference > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53016) in GitLab 13.9. Group repositories can be moved between storages. This API can help you when -[migrating to Gitaly Cluster](../administration/gitaly/praefect.md#migrate-to-gitaly-cluster), for +[migrating to Gitaly Cluster](../administration/gitaly/index.md#migrate-to-gitaly-cluster), for example, or to migrate a [group wiki](../user/project/wiki/index.md#group-wikis). As group repository storage moves are processed, they transition through different states. Values diff --git a/doc/api/project_repository_storage_moves.md b/doc/api/project_repository_storage_moves.md index fe2750fa4bf..ebb15e1c1d6 100644 --- a/doc/api/project_repository_storage_moves.md +++ b/doc/api/project_repository_storage_moves.md @@ -10,7 +10,7 @@ type: reference > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31285) in GitLab 13.0. Project repositories including wiki and design repositories can be moved between storages. This can be useful when -[migrating to Gitaly Cluster](../administration/gitaly/praefect.md#migrate-to-gitaly-cluster), +[migrating to Gitaly Cluster](../administration/gitaly/index.md#migrate-to-gitaly-cluster), for example. As project repository storage moves are processed, they transition through different states. Values diff --git a/doc/api/snippet_repository_storage_moves.md b/doc/api/snippet_repository_storage_moves.md index 9951e073c39..a73542c8505 100644 --- a/doc/api/snippet_repository_storage_moves.md +++ b/doc/api/snippet_repository_storage_moves.md @@ -10,7 +10,7 @@ type: reference > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49228) in GitLab 13.8. Snippet repositories can be moved between storages. This can be useful when -[migrating to Gitaly Cluster](../administration/gitaly/praefect.md#migrate-to-gitaly-cluster), for +[migrating to Gitaly Cluster](../administration/gitaly/index.md#migrate-to-gitaly-cluster), for example. As snippet repository storage moves are processed, they transition through different states. Values diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c73780024ae..d0a4d9feca4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10534,6 +10534,9 @@ msgid_plural "DastSiteValidation|This will affect %d other profiles targeting th msgstr[0] "" msgstr[1] "" +msgid "DastSiteValidation|To run an active scan, validate your target site. All site profiles that share the same base URL share the same validation status." +msgstr "" + msgid "DastSiteValidation|Validate" msgstr "" @@ -31228,6 +31231,15 @@ msgstr "" msgid "SortOptions|Blocking" msgstr "" +msgid "SortOptions|Closed date" +msgstr "" + +msgid "SortOptions|Closed earlier" +msgstr "" + +msgid "SortOptions|Closed recently" +msgstr "" + msgid "SortOptions|Created date" msgstr "" diff --git a/scripts/api/cancel_pipeline.rb b/scripts/api/cancel_pipeline.rb index f3aea90f54f..2de50dcee80 100755 --- a/scripts/api/cancel_pipeline.rb +++ b/scripts/api/cancel_pipeline.rb @@ -1,40 +1,32 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'rubygems' require 'gitlab' require 'optparse' -require_relative 'get_job_id' +require_relative 'default_options' class CancelPipeline - DEFAULT_OPTIONS = { - project: ENV['CI_PROJECT_ID'], - pipeline_id: ENV['CI_PIPELINE_ID'], - # Default to "CI scripts API usage" at https://gitlab.com/gitlab-org/gitlab/-/settings/access_tokens - api_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE'] - }.freeze - def initialize(options) @project = options.delete(:project) @pipeline_id = options.delete(:pipeline_id) - Gitlab.configure do |config| - config.endpoint = 'https://gitlab.com/api/v4' - config.private_token = options.delete(:api_token) - end + @client = Gitlab.client( + endpoint: options.delete(:endpoint) || API::DEFAULT_OPTIONS[:endpoint], + private_token: options.delete(:api_token) + ) end def execute - Gitlab.cancel_pipeline(project, pipeline_id) + client.cancel_pipeline(project, pipeline_id) end private - attr_reader :project, :pipeline_id + attr_reader :project, :pipeline_id, :client end if $0 == __FILE__ - options = CancelPipeline::DEFAULT_OPTIONS.dup + options = API::DEFAULT_OPTIONS.dup OptionParser.new do |opts| opts.on("-p", "--project PROJECT", String, "Project where to find the job (defaults to $CI_PROJECT_ID)") do |value| @@ -45,10 +37,14 @@ if $0 == __FILE__ options[:pipeline_id] = value end - opts.on("-t", "--api-token API_TOKEN", String, "A value API token with the `read_api` scope") do |value| + opts.on("-t", "--api-token API_TOKEN", String, "A value API token with the `api` scope") do |value| options[:api_token] = value end + opts.on("-E", "--endpoint ENDPOINT", String, "The API endpoint for the API token. (defaults to $CI_API_V4_URL and fallback to https://gitlab.com/api/v4)") do |value| + options[:endpoint] = value + end + opts.on("-h", "--help", "Prints this help") do puts opts exit diff --git a/scripts/api/default_options.rb b/scripts/api/default_options.rb new file mode 100644 index 00000000000..70fb9683733 --- /dev/null +++ b/scripts/api/default_options.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module API + DEFAULT_OPTIONS = { + project: ENV['CI_PROJECT_ID'], + pipeline_id: ENV['CI_PIPELINE_ID'], + # Default to "CI scripts API usage" at https://gitlab.com/gitlab-org/gitlab/-/settings/access_tokens + api_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE'], + endpoint: ENV['CI_API_V4_URL'] || 'https://gitlab.com/api/v4' + }.freeze +end diff --git a/scripts/api/download_job_artifact.rb b/scripts/api/download_job_artifact.rb index d725de3f1b6..23202ad3912 100755 --- a/scripts/api/download_job_artifact.rb +++ b/scripts/api/download_job_artifact.rb @@ -1,31 +1,26 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'rubygems' require 'optparse' require 'fileutils' require 'uri' require 'cgi' require 'net/http' +require_relative 'default_options' class ArtifactFinder - DEFAULT_OPTIONS = { - project: ENV['CI_PROJECT_ID'], - # Default to "CI scripts API usage" at https://gitlab.com/gitlab-org/gitlab/-/settings/access_tokens - api_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE'] - }.freeze - def initialize(options) @project = options.delete(:project) @job_id = options.delete(:job_id) @api_token = options.delete(:api_token) + @endpoint = options.delete(:endpoint) || API::DEFAULT_OPTIONS[:endpoint] @artifact_path = options.delete(:artifact_path) warn "No API token given." unless api_token end def execute - url = "https://gitlab.com/api/v4/projects/#{CGI.escape(project)}/jobs/#{job_id}/artifacts" + url = "#{endpoint}/projects/#{CGI.escape(project)}/jobs/#{job_id}/artifacts" if artifact_path FileUtils.mkdir_p(File.dirname(artifact_path)) @@ -37,7 +32,7 @@ class ArtifactFinder private - attr_reader :project, :job_id, :api_token, :artifact_path + attr_reader :project, :job_id, :api_token, :endpoint, :artifact_path def fetch(uri_str, limit = 10) raise 'Too many HTTP redirects' if limit == 0 @@ -66,7 +61,7 @@ class ArtifactFinder end if $0 == __FILE__ - options = ArtifactFinder::DEFAULT_OPTIONS.dup + options = API::DEFAULT_OPTIONS.dup OptionParser.new do |opts| opts.on("-p", "--project PROJECT", String, "Project where to find the job (defaults to $CI_PROJECT_ID)") do |value| @@ -85,6 +80,10 @@ if $0 == __FILE__ options[:api_token] = value end + opts.on("-E", "--endpoint ENDPOINT", String, "The API endpoint for the API token. (defaults to $CI_API_V4_URL and fallback to https://gitlab.com/api/v4)") do |value| + options[:endpoint] = value + end + opts.on("-h", "--help", "Prints this help") do puts opts exit diff --git a/scripts/api/get_job_id.rb b/scripts/api/get_job_id.rb index f6f1f326225..166c9198951 100755 --- a/scripts/api/get_job_id.rb +++ b/scripts/api/get_job_id.rb @@ -1,19 +1,15 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'rubygems' require 'gitlab' require 'optparse' +require_relative 'default_options' class JobFinder - DEFAULT_OPTIONS = { - project: ENV['CI_PROJECT_ID'], - pipeline_id: ENV['CI_PIPELINE_ID'], - pipeline_query: {}, - job_query: {}, - # Default to "CI scripts API usage" at https://gitlab.com/gitlab-org/gitlab/-/settings/access_tokens - api_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE'] - }.freeze + DEFAULT_OPTIONS = API::DEFAULT_OPTIONS.merge( + pipeline_query: {}.freeze, + job_query: {}.freeze + ).freeze def initialize(options) @project = options.delete(:project) @@ -28,10 +24,10 @@ class JobFinder warn "No API token given." if api_token.empty? - Gitlab.configure do |config| - config.endpoint = 'https://gitlab.com/api/v4' - config.private_token = api_token - end + @client = Gitlab.client( + endpoint: options.delete(:endpoint) || DEFAULT_OPTIONS[:endpoint], + private_token: api_token + ) end def execute @@ -40,13 +36,13 @@ class JobFinder private - attr_reader :project, :pipeline_query, :job_query, :pipeline_id, :job_name, :artifact_path + attr_reader :project, :pipeline_query, :job_query, :pipeline_id, :job_name, :artifact_path, :client def find_job_with_artifact return if artifact_path.nil? - Gitlab.pipelines(project, pipeline_query_params).auto_paginate do |pipeline| - Gitlab.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job| + client.pipelines(project, pipeline_query_params).auto_paginate do |pipeline| + client.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job| return job if found_job_with_artifact?(job) # rubocop:disable Cop/AvoidReturnFromBlocks end end @@ -57,8 +53,8 @@ class JobFinder def find_job_with_filtered_pipelines return if pipeline_query.empty? - Gitlab.pipelines(project, pipeline_query_params).auto_paginate do |pipeline| - Gitlab.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job| + client.pipelines(project, pipeline_query_params).auto_paginate do |pipeline| + client.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job| return job if found_job_by_name?(job) # rubocop:disable Cop/AvoidReturnFromBlocks end end @@ -69,7 +65,7 @@ class JobFinder def find_job_in_pipeline return unless pipeline_id - Gitlab.pipeline_jobs(project, pipeline_id, job_query_params).auto_paginate do |job| + client.pipeline_jobs(project, pipeline_id, job_query_params).auto_paginate do |job| return job if found_job_by_name?(job) # rubocop:disable Cop/AvoidReturnFromBlocks end @@ -77,7 +73,7 @@ class JobFinder end def found_job_with_artifact?(job) - artifact_url = "https://gitlab.com/api/v4/projects/#{CGI.escape(project)}/jobs/#{job.id}/artifacts/#{artifact_path}" + artifact_url = "#{client.endpoint}/projects/#{CGI.escape(project)}/jobs/#{job.id}/artifacts/#{artifact_path}" response = HTTParty.head(artifact_url) # rubocop:disable Gitlab/HTTParty response.success? end @@ -108,11 +104,13 @@ if $0 == __FILE__ end opts.on("-q", "--pipeline-query pipeline_query", String, "Query to pass to the Pipeline API request") do |value| - options[:pipeline_query].merge!(Hash[*value.split('=')]) + options[:pipeline_query] = + options[:pipeline_query].merge(Hash[*value.split('=')]) end opts.on("-Q", "--job-query job_query", String, "Query to pass to the Job API request") do |value| - options[:job_query].merge!(Hash[*value.split('=')]) + options[:job_query] = + options[:job_query].merge(Hash[*value.split('=')]) end opts.on("-j", "--job-name job_name", String, "A job name that needs to exist in the found pipeline") do |value| @@ -127,6 +125,10 @@ if $0 == __FILE__ options[:api_token] = value end + opts.on("-E", "--endpoint ENDPOINT", String, "The API endpoint for the API token. (defaults to $CI_API_V4_URL and fallback to https://gitlab.com/api/v4)") do |value| + options[:endpoint] = value + end + opts.on("-h", "--help", "Prints this help") do puts opts exit diff --git a/scripts/api/play_job.rb b/scripts/api/play_job.rb deleted file mode 100755 index 2c5cc75619d..00000000000 --- a/scripts/api/play_job.rb +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'rubygems' -require 'gitlab' -require 'optparse' -require_relative 'get_job_id' - -class PlayJob - DEFAULT_OPTIONS = { - project: ENV['CI_PROJECT_ID'], - pipeline_id: ENV['CI_PIPELINE_ID'], - # Default to "CI scripts API usage" at https://gitlab.com/gitlab-org/gitlab/-/settings/access_tokens - api_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE'] - }.freeze - - def initialize(options) - @options = options - - Gitlab.configure do |config| - config.endpoint = 'https://gitlab.com/api/v4' - config.private_token = options.fetch(:api_token) - end - end - - def execute - job = JobFinder.new(options.slice(:project, :api_token, :pipeline_id, :job_name).merge(scope: 'manual')).execute - - Gitlab.job_play(project, job.id) - end - - private - - attr_reader :options - - def project - options[:project] - end -end - -if $0 == __FILE__ - options = PlayJob::DEFAULT_OPTIONS.dup - - OptionParser.new do |opts| - opts.on("-p", "--project PROJECT", String, "Project where to find the job (defaults to $CI_PROJECT_ID)") do |value| - options[:project] = value - end - - opts.on("-j", "--job-name JOB_NAME", String, "A job name that needs to exist in the found pipeline") do |value| - options[:job_name] = value - end - - opts.on("-t", "--api-token API_TOKEN", String, "A value API token with the `read_api` scope") do |value| - options[:api_token] = value - end - - opts.on("-h", "--help", "Prints this help") do - puts opts - exit - end - end.parse! - - PlayJob.new(options).execute -end diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index a788c3417c2..0714ecfce80 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -10,14 +10,14 @@ function retrieve_tests_metadata() { local test_metadata_job_id # Ruby - test_metadata_job_id=$(scripts/api/get_job_id.rb --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata") + test_metadata_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata") if [[ ! -f "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" ]]; then - scripts/api/download_job_artifact.rb --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" + scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" fi if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then - scripts/api/download_job_artifact.rb --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}" + scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}" fi } @@ -48,10 +48,10 @@ function retrieve_tests_mapping() { local artifact_branch="master" local test_metadata_with_mapping_job_id - test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") + test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then - (scripts/api/download_job_artifact.rb --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + (scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}" fi scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}" diff --git a/spec/features/merge_requests/user_lists_merge_requests_spec.rb b/spec/features/merge_requests/user_lists_merge_requests_spec.rb index ab6242784fe..f96717970bf 100644 --- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb +++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Merge requests > User lists merge requests' do milestone: create(:milestone, project: project, due_date: '2013-12-11'), created_at: 1.minute.ago, updated_at: 1.minute.ago) - @fix.metrics.update_column(:merged_at, 10.seconds.ago) + @fix.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 10.seconds.ago) @markdown = create(:merge_request, title: 'markdown', @@ -34,7 +34,7 @@ RSpec.describe 'Merge requests > User lists merge requests' do milestone: create(:milestone, project: project, due_date: '2013-12-12'), created_at: 2.minutes.ago, updated_at: 2.minutes.ago) - @markdown.metrics.update_column(:merged_at, 50.seconds.ago) + @markdown.metrics.update!(merged_at: 10.minutes.ago, latest_closed_at: 10.seconds.ago) @merge_test = create(:merge_request, title: 'merge-test', @@ -42,7 +42,15 @@ RSpec.describe 'Merge requests > User lists merge requests' do source_branch: 'merge-test', created_at: 3.minutes.ago, updated_at: 10.seconds.ago) - @merge_test.metrics.update_column(:merged_at, 10.seconds.ago) + @merge_test.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 10.seconds.ago) + + @feature = create(:merge_request, + title: 'feature', + source_project: project, + source_branch: 'feautre', + created_at: 2.minutes.ago, + updated_at: 1.minute.ago) + @feature.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 10.minutes.ago) end context 'merge request reviewers' do @@ -71,9 +79,10 @@ RSpec.describe 'Merge requests > User lists merge requests' do expect(current_path).to eq(project_merge_requests_path(project)) expect(page).to have_content 'merge-test' + expect(page).to have_content 'feature' expect(page).not_to have_content 'fix' expect(page).not_to have_content 'markdown' - expect(count_merge_requests).to eq(1) + expect(count_merge_requests).to eq(2) end it 'filters on a specific assignee' do @@ -90,28 +99,35 @@ RSpec.describe 'Merge requests > User lists merge requests' do expect(first_merge_request).to include('fix') expect(last_merge_request).to include('merge-test') - expect(count_merge_requests).to eq(3) + expect(count_merge_requests).to eq(4) end it 'sorts by last updated' do visit_merge_requests(project, sort: sort_value_recently_updated) expect(first_merge_request).to include('merge-test') - expect(count_merge_requests).to eq(3) + expect(count_merge_requests).to eq(4) end it 'sorts by milestone' do visit_merge_requests(project, sort: sort_value_milestone) expect(first_merge_request).to include('fix') - expect(count_merge_requests).to eq(3) + expect(count_merge_requests).to eq(4) end it 'sorts by merged at' do visit_merge_requests(project, sort: sort_value_merged_date) expect(first_merge_request).to include('markdown') - expect(count_merge_requests).to eq(3) + expect(count_merge_requests).to eq(4) + end + + it 'sorts by closed at' do + visit_merge_requests(project, sort: sort_value_closed_date) + + expect(first_merge_request).to include('feature') + expect(count_merge_requests).to eq(4) end it 'filters on one label and sorts by due date' do diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 74f579e77ed..d3e1bfef561 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -86,7 +86,7 @@ describe('AuthorToken', () => { }); describe('methods', () => { - describe('fetchAuthorBySearchTerm', () => { + describe('fetchAuthors', () => { beforeEach(() => { wrapper = createComponent(); }); @@ -155,7 +155,7 @@ describe('AuthorToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ suggestions: mockAuthors, - fnActiveTokenValue: wrapper.vm.getActiveAuthor, + getActiveTokenValue: wrapper.vm.getActiveAuthor, }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index cd6ffd679d0..c746cb7749a 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -53,7 +53,6 @@ const mockProps = { suggestionsLoading: false, defaultSuggestions: DEFAULT_LABELS, recentSuggestionsStorageKey: mockStorageKey, - fnCurrentTokenValue: jest.fn(), }; function createComponent({ @@ -99,31 +98,20 @@ describe('BaseToken', () => { }); describe('computed', () => { - describe('currentTokenValue', () => { - it('calls `fnCurrentTokenValue` when it is provided', () => { - // We're disabling lint to trigger computed prop execution for this test. - // eslint-disable-next-line no-unused-vars - const { currentTokenValue } = wrapper.vm; - - expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`); - }); - }); - describe('activeTokenValue', () => { - it('calls `fnActiveTokenValue` when it is provided', async () => { - const mockFnActiveTokenValue = jest.fn(); + it('calls `getActiveTokenValue` when it is provided', async () => { + const mockGetActiveTokenValue = jest.fn(); wrapper.setProps({ - fnActiveTokenValue: mockFnActiveTokenValue, - fnCurrentTokenValue: undefined, + getActiveTokenValue: mockGetActiveTokenValue, }); await wrapper.vm.$nextTick(); - expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1); - expect(mockFnActiveTokenValue).toHaveBeenCalledWith( + expect(mockGetActiveTokenValue).toHaveBeenCalledTimes(1); + expect(mockGetActiveTokenValue).toHaveBeenCalledWith( mockLabels, - `"${mockRegularLabel.title.toLowerCase()}"`, + `"${mockRegularLabel.title}"`, ); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index ec9458f64d2..e5ffbd41afa 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -98,11 +98,11 @@ describe('LabelToken', () => { }); }); - describe('fetchLabelBySearchTerm', () => { + describe('fetchLabels', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo'); }); @@ -110,7 +110,7 @@ describe('LabelToken', () => { it('sets response to `labels` when request is succesful', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(wrapper.vm.labels).toEqual(mockLabels); @@ -120,7 +120,7 @@ describe('LabelToken', () => { it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -132,7 +132,7 @@ describe('LabelToken', () => { it('sets `loading` to false when request completes', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(wrapper.vm.loading).toBe(false); @@ -160,7 +160,7 @@ describe('LabelToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ suggestions: mockLabels, - fnActiveTokenValue: wrapper.vm.getActiveLabel, + getActiveTokenValue: wrapper.vm.getActiveLabel, }); }); diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index aec6c6c6708..64ee0d4f9cc 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -303,6 +303,29 @@ RSpec.describe Resolvers::MergeRequestsResolver do expect { resolve_mr(project, sort: :merged_at_desc, labels: %w[a b]) }.not_to raise_error end end + + context 'when sorting by closed at' do + before do + merge_request_1.metrics.update!(latest_closed_at: 10.days.ago) + merge_request_3.metrics.update!(latest_closed_at: 5.days.ago) + end + + it 'sorts merge requests ascending' do + expect(resolve_mr(project, sort: :closed_at_asc)) + .to match_array(mrs) + .and be_sorted(->(mr) { [closed_at(mr), -mr.id] }) + end + + it 'sorts merge requests descending' do + expect(resolve_mr(project, sort: :closed_at_desc)) + .to match_array(mrs) + .and be_sorted(->(mr) { [-closed_at(mr), -mr.id] }) + end + + def closed_at(mr) + nils_last(mr.metrics.latest_closed_at) + end + end end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index edd543854cb..4a8a2909891 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -80,6 +80,24 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '.order_closed_at_asc' do + let_it_be(:older_mr) { create(:merge_request, :closed_last_month) } + let_it_be(:newer_mr) { create(:merge_request, :closed_last_month) } + + it 'returns MRs ordered by closed_at ascending' do + expect(described_class.order_closed_at_asc).to eq([older_mr, newer_mr]) + end + end + + describe '.order_closed_at_desc' do + let_it_be(:older_mr) { create(:merge_request, :closed_last_month) } + let_it_be(:newer_mr) { create(:merge_request, :closed_last_month) } + + it 'returns MRs ordered by closed_at descending' do + expect(described_class.order_closed_at_desc).to eq([newer_mr, older_mr]) + end + end + describe '.with_jira_issue_keys' do let_it_be(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'Fix TEST-123') } let_it_be(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'this closes TEST-321') } @@ -577,6 +595,26 @@ RSpec.describe MergeRequest, factory_default: :keep do expect(merge_requests).to eq([newer_mr, older_mr]) end end + + context 'closed_at' do + let_it_be(:older_mr) { create(:merge_request, :closed_last_month) } + let_it_be(:newer_mr) { create(:merge_request, :closed_last_month) } + + it 'sorts asc' do + merge_requests = described_class.sort_by_attribute(:closed_at_asc) + expect(merge_requests).to eq([older_mr, newer_mr]) + end + + it 'sorts desc' do + merge_requests = described_class.sort_by_attribute(:closed_at_desc) + expect(merge_requests).to eq([newer_mr, older_mr]) + end + + it 'sorts asc when its closed_at' do + merge_requests = described_class.sort_by_attribute(:closed_at) + expect(merge_requests).to eq([older_mr, newer_mr]) + end + end end describe 'time to merge calculations' do diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index 7fc1ef05fa7..1b0405be09c 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -422,6 +422,46 @@ RSpec.describe 'getting merge request listings nested in a project' do end end end + + context 'when sorting by closed_at DESC' do + let(:sort_param) { :CLOSED_AT_DESC } + let(:expected_results) do + [ + merge_request_b, + merge_request_d, + merge_request_c, + merge_request_e, + merge_request_a + ].map { |mr| global_id_of(mr) } + end + + before do + five_days_ago = 5.days.ago + + merge_request_d.metrics.update!(latest_closed_at: five_days_ago) + + # same merged_at, the second order column will decide (merge_request.id) + merge_request_c.metrics.update!(latest_closed_at: five_days_ago) + + merge_request_b.metrics.update!(latest_closed_at: 1.day.ago) + end + + it_behaves_like 'sorted paginated query' do + let(:first_param) { 2 } + end + + context 'when last parameter is given' do + let(:params) { graphql_args(sort: sort_param, last: 2) } + let(:page_info) { nil } + + it 'takes the last 2 records' do + query = pagination_query(params) + post_graphql(query, current_user: current_user) + + expect(results.map { |item| item["id"] }).to eq(expected_results.last(2)) + end + end + end end context 'when only the count is requested' do |