import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import {
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ DEPENDENCY_PROXY_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
@@ -19,9 +22,6 @@ export default {
},
inject: ['groupPath', 'dependencyProxyAvailable'],
i18n: {
- subTitle: __(
- 'Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
- ),
proxyNotAvailableText: __('Dependency proxy feature is limited to public groups for now.'),
proxyImagePrefix: __('Dependency proxy image prefix'),
copyImagePrefixText: __('Copy prefix'),
@@ -47,8 +47,8 @@ export default {
infoMessages() {
return [
{
- text: this.$options.i18n.subTitle,
- link: helpPagePath('user/packages/dependency_proxy/index'),
+ text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ link: DEPENDENCY_PROXY_DOCS_PATH,
},
];
},
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index ec3be43196c..954a2aedf6b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,108 +1,54 @@
+
+
+
+ {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}
+
+
+
+
+ {{
+ content
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index d29489a0b33..2824d5e2776 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -23,8 +23,15 @@ export const ERROR_UPDATING_SETTINGS = s__(
'PackageRegistry|An error occurred while saving the settings',
);
+export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
+export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__(
+ 'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
+);
+
// Parameters
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages');
export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
+
+export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ebd20583a1c..b350db0c838 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
+import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
humanize,
@@ -9,6 +11,23 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
+const invalidInputClass = 'gl-field-error-outline';
+
+const validateImportCredentials = (url, user, password) => {
+ const endpoint = `${gon.relative_url_root}/import/url/validate`;
+ return axios
+ .post(endpoint, {
+ url,
+ user,
+ password,
+ })
+ .then(({ data }) => data)
+ .catch(() => ({
+ // intentionally reporting success in case of validation error
+ // we do not want to block users from trying import in case of validation exception
+ success: true,
+ }));
+};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
@@ -85,7 +104,10 @@ const bindHowToImport = () => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
- const $projectImportUrlWarning = $('.js-import-url-warning');
+ const $projectImportUrlUser = $('#project_import_url_user');
+ const $projectImportUrlPassword = $('#project_import_url_password');
+ const $projectImportUrlError = $('.js-import-url-error');
+ const $projectImportForm = $('.project-import form');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -139,12 +161,15 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- function updateUrlPathWarningVisibility() {
- const url = $projectImportUrl.val();
- const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/;
- const isUrlValid = URL_PATTERN.test(url);
- $projectImportUrlWarning.toggleClass('hide', isUrlValid);
- }
+ const updateUrlPathWarningVisibility = debounce(async () => {
+ const { success: isUrlValid } = await validateImportCredentials(
+ $projectImportUrl.val(),
+ $projectImportUrlUser.val(),
+ $projectImportUrlPassword.val(),
+ );
+ $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
+ $projectImportUrlError.toggleClass('hide', isUrlValid);
+ }, 500);
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
@@ -153,9 +178,22 @@ const bindEvents = () => {
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
- // defer error message till first input blur
- if (isProjectImportUrlDirty) {
- updateUrlPathWarningVisibility();
+ });
+
+ [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
+ $f.on('input', () => {
+ if (isProjectImportUrlDirty) {
+ updateUrlPathWarningVisibility();
+ }
+ });
+ });
+
+ $projectImportForm.on('submit', (e) => {
+ const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
+ if ($invalidFields.length > 0) {
+ $invalidFields[0].focus();
+ e.preventDefault();
+ e.stopPropagation();
}
});
diff --git a/app/controllers/import/url_controller.rb b/app/controllers/import/url_controller.rb
new file mode 100644
index 00000000000..4e4b6ad125e
--- /dev/null
+++ b/app/controllers/import/url_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Import::UrlController < ApplicationController
+ feature_category :importers
+
+ def validate
+ result = Import::ValidateRemoteGitEndpointService.new(validate_params).execute
+ if result.success?
+ render json: { success: true }
+ else
+ render json: { success: false, message: result.message }
+ end
+ end
+
+ private
+
+ def validate_params
+ params.permit(:user, :password, :url)
+ end
+end
diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb
index 2bbc963aa90..f4712fa6879 100644
--- a/app/finders/issuables/label_filter.rb
+++ b/app/finders/issuables/label_filter.rb
@@ -89,17 +89,25 @@ module Issuables
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def find_label_ids(label_names)
- group_labels = Label
- .where(project_id: nil)
- .where(title: label_names)
- .where(group_id: root_namespace.self_and_descendant_ids)
+ find_label_ids_uncached(label_names)
+ end
+ # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
+ request_cache(:find_label_ids) { root_namespace.id }
- project_labels = Label
- .where(group_id: nil)
- .where(title: label_names)
- .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
+ # This returns an array of label IDs per label name. It is possible for a label name
+ # to have multiple IDs because we allow labels with the same name if they are on a different
+ # project or group.
+ #
+ # For example, if we pass in `['bug', 'feature']`, this will return something like:
+ # `[ [1, 2], [3] ]`
+ #
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_label_ids_uncached(label_names)
+ return [] if label_names.empty?
+
+ group_labels = group_labels_for_root_namespace.where(title: label_names)
+ project_labels = project_labels_for_root_namespace.where(title: label_names)
Label
.from_union([group_labels, project_labels], remove_duplicates: false)
@@ -109,8 +117,18 @@ module Issuables
.values
.map { |labels| labels.map(&:last) }
end
- # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
- request_cache(:find_label_ids) { root_namespace.id }
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def group_labels_for_root_namespace
+ Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_labels_for_root_namespace
+ Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
+ end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
@@ -153,3 +171,5 @@ module Issuables
end
end
end
+
+Issuables::LabelFilter.prepend_mod
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index a656856487d..7f96b3901f1 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -2,6 +2,15 @@
module VulnerabilityFindingHelpers
extend ActiveSupport::Concern
+
+ # Manually resolvable report types cannot be considered fixed once removed from the
+ # target branch due to requiring active triage, such as rotation of an exposed token.
+ REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION = %w[secret_detection].freeze
+
+ def requires_manual_resolution?
+ REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type)
+ end
+
def matches_signatures(other_signatures, other_uuid)
other_signature_types = other_signatures.index_by(&:algorithm_type)
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index fb26d5d3356..664915c5e2f 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -11,8 +11,6 @@ module Ci
def execute
increment_processing_counter
- update_retried
-
Ci::PipelineProcessing::AtomicProcessingService
.new(pipeline)
.execute
@@ -24,41 +22,6 @@ module Ci
private
- # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
- # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb
- # and ensures that functionality will not be broken before migration is run
- # this updates only when there are data that needs to be updated, there are two groups with no retried flag
- # rubocop: disable CodeReuse/ActiveRecord
- def update_retried
- return if Feature.enabled?(:ci_remove_update_retried_from_process_pipeline, pipeline.project, default_enabled: :yaml)
-
- # find the latest builds for each name
- latest_statuses = pipeline.latest_statuses
- .group(:name)
- .having('count(*) > 1')
- .pluck(Arel.sql('MAX(id)'), 'name')
-
- # mark builds that are retried
- if latest_statuses.any?
- updated_count = pipeline.latest_statuses
- .where(name: latest_statuses.map(&:second))
- .where.not(id: latest_statuses.map(&:first))
- .update_all(retried: true)
-
- # This counter is temporary. It will be used to check whether if we still use this method or not
- # after setting correct value of `GenericCommitStatus#retried`.
- # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115
- if updated_count > 0
- Gitlab::AppJsonLogger.info(event: 'update_retried_is_used',
- project_id: pipeline.project.id,
- pipeline_id: pipeline.id)
-
- metrics.legacy_update_jobs_counter.increment
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def increment_processing_counter
metrics.pipeline_processing_events_counter.increment
end
diff --git a/app/services/ci/stuck_builds/drop_running_service.rb b/app/services/ci/stuck_builds/drop_running_service.rb
index ef23bd7e7bd..c543d8a94db 100644
--- a/app/services/ci/stuck_builds/drop_running_service.rb
+++ b/app/services/ci/stuck_builds/drop_running_service.rb
@@ -16,7 +16,12 @@ module Ci
private
def running_timed_out_builds
- Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ if Feature.enabled?(:ci_new_query_for_running_stuck_jobs, default_enabled: :yaml)
+ running_builds = Ci::Build.running.created_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago).order(created_at: :asc, project_id: :asc) # rubocop: disable CodeReuse/ActiveRecord
+ Ci::Build.id_in(running_builds).updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ else
+ Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ end
end
end
end
diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb
new file mode 100644
index 00000000000..47324e20348
--- /dev/null
+++ b/app/services/import/validate_remote_git_endpoint_service.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Import
+ class ValidateRemoteGitEndpointService
+ # Validates if the remote endpoint is a valid GIT repository
+ # Only smart protocol is supported
+ # Validation rules are taken from https://git-scm.com/docs/http-protocol#_smart_clients
+
+ GIT_SERVICE_NAME = "git-upload-pack"
+ GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}"
+ GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze
+ # https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59
+ GIT_PROTOCOL_PKT_LEN = 4
+ GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length
+ EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement"
+
+ def initialize(params)
+ @params = params
+ end
+
+ def execute
+ uri = Gitlab::Utils.parse_url(@params[:url])
+
+ return error("Invalid URL") unless uri
+
+ uri.fragment = nil
+ url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}")
+
+ response_body = ''
+ result = nil
+ Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment|
+ response_body += fragment
+ next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH
+
+ result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body)
+ :success
+ else
+ :error
+ end
+
+ # We are interested only in the first chunks of the response
+ # So we're using stream_body: true and breaking when receive enough body
+ break
+ end
+
+ if result == :success
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository")
+ end
+ end
+
+ private
+
+ def auth
+ unless @params[:user].to_s.blank?
+ {
+ username: @params[:user],
+ password: @params[:password]
+ }
+ end
+ end
+
+ def status_code_is_valid(fragment)
+ fragment.http_response.code == '200'
+ end
+
+ def content_type_is_valid(fragment)
+ fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE
+ end
+
+ def response_body_is_valid(response_body)
+ response_body.match?(GIT_BODY_MESSAGE_REGEXP)
+ end
+ end
+end
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index 0daadd20f54..c65b947d1ba 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -2,13 +2,7 @@
%h1.page-title= _('Activity')
.top-area
- %ul.nav-links.nav.nav-tabs
- %li{ class: active_when(params[:filter].nil?) }>
- = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- = _('Your projects')
- %li{ class: active_when(params[:filter] == 'starred') }>
- = link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
- = _('Starred projects')
- %li{ class: active_when(params[:filter] == 'followed') }>
- = link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
- = _('Followed users')
+ = gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do
+ = gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? }
+ = gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred')
+ = gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed')
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 815a3cf6966..81d9726fcdc 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- = form_for @project, html: { class: 'new_project' } do |f|
+ = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index f03314563cb..3ab2b969b75 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -9,17 +9,12 @@
= f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
= render 'shared/global_alert',
- variant: :warning,
- alert_class: 'gl-mt-3 js-import-url-warning hide',
+ variant: :danger,
+ alert_class: 'gl-mt-3 js-import-url-error hide',
dismissible: false,
close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body
- = s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.')
-
- .gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning
- .gl-alert-container
- = sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content{ role: 'alert' }
+ = s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
.row
.form-group.col-md-6
= f.label :import_url_user, class: 'label-bold' do
diff --git a/config/feature_flags/development/ci_remove_update_retried_from_process_pipeline.yml b/config/feature_flags/development/ci_new_query_for_running_stuck_jobs.yml
similarity index 50%
rename from config/feature_flags/development/ci_remove_update_retried_from_process_pipeline.yml
rename to config/feature_flags/development/ci_new_query_for_running_stuck_jobs.yml
index 932ee766340..345e9b4c3ae 100644
--- a/config/feature_flags/development/ci_remove_update_retried_from_process_pipeline.yml
+++ b/config/feature_flags/development/ci_new_query_for_running_stuck_jobs.yml
@@ -1,8 +1,8 @@
---
-name: ci_remove_update_retried_from_process_pipeline
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54300
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321630
-milestone: '13.9'
+name: ci_new_query_for_running_stuck_jobs
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71013
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339264
+milestone: '14.4'
type: development
-group: group::pipeline authoring
-default_enabled: true
+group: group::pipeline execution
+default_enabled: false
diff --git a/config/feature_flags/development/reference_cache_memoization.yml b/config/feature_flags/development/reference_cache_memoization.yml
new file mode 100644
index 00000000000..795d9497f9d
--- /dev/null
+++ b/config/feature_flags/development/reference_cache_memoization.yml
@@ -0,0 +1,8 @@
+---
+name: reference_cache_memoization
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71310
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341849
+milestone: '14.4'
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/feature_flags/development/security_report_ingestion_framework.yml b/config/feature_flags/development/security_report_ingestion_framework.yml
new file mode 100644
index 00000000000..490fd03c677
--- /dev/null
+++ b/config/feature_flags/development/security_report_ingestion_framework.yml
@@ -0,0 +1,8 @@
+---
+name: security_report_ingestion_framework
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66735
+rollout_issue_url:
+milestone: '14.4'
+type: development
+group: group::threat insights
+default_enabled: false
diff --git a/config/feature_flags/development/use_cte_for_any_project_with_shared_runners_enabled.yml b/config/feature_flags/development/use_cte_for_any_project_with_shared_runners_enabled.yml
new file mode 100644
index 00000000000..59c921ff4c9
--- /dev/null
+++ b/config/feature_flags/development/use_cte_for_any_project_with_shared_runners_enabled.yml
@@ -0,0 +1,8 @@
+---
+name: use_cte_for_any_project_with_shared_runners_enabled
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71452
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/342024
+milestone: '14.4'
+type: development
+group: group::optimize
+default_enabled: false
diff --git a/config/routes/import.rb b/config/routes/import.rb
index 64830ef1e52..c35b67497da 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -12,6 +12,10 @@ end
namespace :import do
resources :available_namespaces, only: [:index], controller: :available_namespaces
+ namespace :url do
+ post :validate
+ end
+
resource :github, only: [:create, :new], controller: :github do
post :personal_access_token
get :status
diff --git a/db/migrate/20210921063924_index_labels_using_varchar_pattern_ops.rb b/db/migrate/20210921063924_index_labels_using_varchar_pattern_ops.rb
new file mode 100644
index 00000000000..67975636488
--- /dev/null
+++ b/db/migrate/20210921063924_index_labels_using_varchar_pattern_ops.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class IndexLabelsUsingVarcharPatternOps < Gitlab::Database::Migration[1.0]
+ disable_ddl_transaction!
+
+ NEW_TITLE_INDEX_NAME = 'index_labels_on_title_varchar'
+ NEW_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_varchar_unique'
+ NEW_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_varchar_unique'
+ NEW_GROUP_ID_INDEX_NAME = 'index_labels_on_group_id'
+
+ OLD_TITLE_INDEX_NAME = 'index_labels_on_title'
+ OLD_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_unique'
+ OLD_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_unique'
+ OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_project_id_and_title'
+
+ def up
+ add_concurrent_index :labels, :title, order: { title: :varchar_pattern_ops }, name: NEW_TITLE_INDEX_NAME
+ add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_PROJECT_ID_TITLE_INDEX_NAME
+ add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_GROUP_ID_TITLE_INDEX_NAME
+ add_concurrent_index :labels, :group_id, name: NEW_GROUP_ID_INDEX_NAME
+
+ remove_concurrent_index_by_name :labels, OLD_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, OLD_PROJECT_ID_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, OLD_GROUP_ID_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :labels, :title, name: OLD_TITLE_INDEX_NAME
+ add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, name: OLD_PROJECT_ID_TITLE_INDEX_NAME
+ add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, name: OLD_GROUP_ID_TITLE_INDEX_NAME
+ add_concurrent_index :labels, [:group_id, :project_id, :title], unique: true, name: OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
+
+ remove_concurrent_index_by_name :labels, NEW_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, NEW_PROJECT_ID_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, NEW_GROUP_ID_TITLE_INDEX_NAME
+ remove_concurrent_index_by_name :labels, NEW_GROUP_ID_INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20210921063924 b/db/schema_migrations/20210921063924
new file mode 100644
index 00000000000..ed849aa174c
--- /dev/null
+++ b/db/schema_migrations/20210921063924
@@ -0,0 +1 @@
+4430d4e0d688c85768201ab09056d60151fdc949b4b5f4ebc5397a99b9ec5f83
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 80689a24f2a..9506bc96f01 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -25451,17 +25451,17 @@ CREATE INDEX index_label_priorities_on_priority ON label_priorities USING btree
CREATE UNIQUE INDEX index_label_priorities_on_project_id_and_label_id ON label_priorities USING btree (project_id, label_id);
-CREATE UNIQUE INDEX index_labels_on_group_id_and_project_id_and_title ON labels USING btree (group_id, project_id, title);
+CREATE INDEX index_labels_on_group_id ON labels USING btree (group_id);
-CREATE UNIQUE INDEX index_labels_on_group_id_and_title_unique ON labels USING btree (group_id, title) WHERE (project_id IS NULL);
+CREATE UNIQUE INDEX index_labels_on_group_id_and_title_varchar_unique ON labels USING btree (group_id, title varchar_pattern_ops) WHERE (project_id IS NULL);
CREATE INDEX index_labels_on_project_id ON labels USING btree (project_id);
-CREATE UNIQUE INDEX index_labels_on_project_id_and_title_unique ON labels USING btree (project_id, title) WHERE (group_id IS NULL);
+CREATE UNIQUE INDEX index_labels_on_project_id_and_title_varchar_unique ON labels USING btree (project_id, title varchar_pattern_ops) WHERE (group_id IS NULL);
CREATE INDEX index_labels_on_template ON labels USING btree (template) WHERE template;
-CREATE INDEX index_labels_on_title ON labels USING btree (title);
+CREATE INDEX index_labels_on_title_varchar ON labels USING btree (title varchar_pattern_ops);
CREATE INDEX index_labels_on_type_and_project_id ON labels USING btree (type, project_id);
diff --git a/doc/administration/geo/disaster_recovery/planned_failover.md b/doc/administration/geo/disaster_recovery/planned_failover.md
index a7a64701cbd..6312ed669ae 100644
--- a/doc/administration/geo/disaster_recovery/planned_failover.md
+++ b/doc/administration/geo/disaster_recovery/planned_failover.md
@@ -162,6 +162,9 @@ be disabled on the **primary** site:
## Finish replicating and verifying all data
+NOTE:
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
1. If you are manually replicating any data not managed by Geo, trigger the
final replication process now.
1. On the **primary** node:
@@ -192,12 +195,13 @@ At this point, your **secondary** node contains an up-to-date copy of everything
## Promote the **secondary** node
-Finally, follow the [Disaster Recovery docs](index.md) to promote the
-**secondary** node to a **primary** node. This process causes a brief outage on the **secondary** node, and users may need to log in again.
+After the replication is finished, [promote the **secondary** node to a **primary** node](index.md). This process causes a brief outage on the **secondary** node, and users may need to log in again. If you follow the steps correctly, the old primary Geo site should still be disabled and user traffic should go to the newly-promoted site instead.
-Once it is completed, the maintenance window is over! Your new **primary** node, now
-begin to diverge from the old one. If problems do arise at this point, failing
+When the promotion is completed, the maintenance window is over, and your new **primary** node now
+begins to diverge from the old one. If problems do arise at this point, failing
back to the old **primary** node [is possible](bring_primary_back.md), but likely to result
in the loss of any data uploaded to the new **primary** in the meantime.
-Don't forget to remove the broadcast message after failover is complete.
+Don't forget to remove the broadcast message after the failover is complete.
+
+Finally, you can bring the [old site back as a secondary](bring_primary_back.md#configure-the-former-primary-node-to-be-a-secondary-node).
diff --git a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md
index 4255fba83f6..3eb7bc2a8e0 100644
--- a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md
+++ b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md
@@ -63,6 +63,9 @@ Before following any of those steps, make sure you have `root` access to the
**secondary** to promote it, since there isn't provided an automated way to
promote a Geo replica and perform a failover.
+NOTE:
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
On the **secondary** node:
1. On the top bar, select **Menu > Admin**.
diff --git a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md
index 18923da1056..d4782144df8 100644
--- a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md
+++ b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md
@@ -51,6 +51,9 @@ Before following any of those steps, make sure you have `root` access to the
**secondary** to promote it, since there isn't provided an automated way to
promote a Geo replica and perform a failover.
+NOTE:
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
On the **secondary** node, navigate to the **Admin Area > Geo** dashboard to
review its status. Replicated objects (shown in green) should be close to 100%,
and there should be no failures (shown in red). If a large proportion of
diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md
index cdb9f111454..d8b1cd129ab 100644
--- a/doc/administration/geo/replication/troubleshooting.md
+++ b/doc/administration/geo/replication/troubleshooting.md
@@ -83,7 +83,7 @@ Checking Geo ... Finished
#### Sync status Rake task
Current sync information can be found manually by running this Rake task on any
-**secondary** app node:
+node running Rails (Puma, Sidekiq, or Geo Log Cursor) on the Geo **secondary** site:
```shell
sudo gitlab-rake geo:status
@@ -923,6 +923,14 @@ To resolve this issue:
If using a load balancer, ensure that the load balancer's URL is set as the `external_url` in the
`/etc/gitlab/gitlab.rb` of the nodes behind the load balancer.
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+In GitLab 13.9 through GitLab 14.3, when [GitLab Maintenance Mode](../../maintenance_mode/index.md) is enabled, the status of Geo secondary sites will stop getting updated. After 10 minutes, the status will become `Unhealthy`.
+
+Geo secondary sites will continue to replicate and verify data, and the secondary sites should still be usable. You can use the [Sync status Rake task](#sync-status-rake-task) to determine the actual status of a secondary site during Maintenance Mode.
+
+This bug was [fixed in GitLab 14.4](https://gitlab.com/gitlab-org/gitlab/-/issues/292983).
+
### GitLab Pages return 404 errors after promoting
This is due to [Pages data not being managed by Geo](datatypes.md#limitations-on-replicationverification).
diff --git a/doc/administration/geo/replication/version_specific_updates.md b/doc/administration/geo/replication/version_specific_updates.md
index 84193e6baac..1b22a5f0991 100644
--- a/doc/administration/geo/replication/version_specific_updates.md
+++ b/doc/administration/geo/replication/version_specific_updates.md
@@ -13,6 +13,8 @@ for updating Geo nodes.
## Updating to 14.1, 14.2, 14.3
+### Multi-arch images
+
We found an [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/336013) where the Container Registry replication wasn't fully working if you used multi-arch images. In case of a multi-arch image, only the primary architecture (for example `amd64`) would be replicated to the secondary node. This has been [fixed in GitLab 14.3](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67624) and was backported to 14.2 and 14.1, but manual steps are required to force a re-sync.
You can check if you are affected by running:
@@ -46,18 +48,28 @@ Otherwise, on all your **secondary** nodes, in a [Rails console](../../operation
If you are running a version prior to 14.1 and are using Geo and multi-arch containers in your Container Registry, we recommend [upgrading](updating_the_geo_sites.md) to at least GitLab 14.1.
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
## Updating to GitLab 14.0/14.1
+### Primary sites can not be removed from the UI
+
We found an issue where [Primary sites can not be removed from the UI](https://gitlab.com/gitlab-org/gitlab/-/issues/338231).
This bug only exists in the UI and does not block the removal of Primary sites using any other method.
-### If you have already updated to an affected version and need to remove your Primary site
+If you are running an affected version and need to remove your Primary site, you can manually remove the Primary site by using the [Geo Nodes API](../../../api/geo_nodes.md#delete-a-geo-node).
-You can manually remove the Primary site by using the [Geo Nodes API](../../../api/geo_nodes.md#delete-a-geo-node).
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.12
+### Secondary nodes re-download all LFS files upon update
+
We found an issue where [secondary nodes re-download all LFS files](https://gitlab.com/gitlab-org/gitlab/-/issues/334550) upon update. This bug:
- Only applies to Geo secondary sites that have replicated LFS objects.
@@ -68,7 +80,7 @@ We found an issue where [secondary nodes re-download all LFS files](https://gitl
If you don't have many LFS objects or can stand a bit of churn, then it is safe to let the secondary sites re-download LFS objects.
If you do have many LFS objects, or many Geo secondary sites, or limited bandwidth, or a combination of them all, then we recommend you skip GitLab 13.12.0 through 13.12.6 and update to GitLab 13.12.7 or newer.
-### If you have already updated to an affected version, and the re-sync is ongoing
+#### If you have already updated to an affected version, and the re-sync is ongoing
You can manually migrate the legacy sync state to the new state column by running the following command in a [Rails console](../../operations/rails_console.md). It should take under a minute:
@@ -76,15 +88,31 @@ You can manually migrate the legacy sync state to the new state column by runnin
Geo::LfsObjectRegistry.where(state: 0, success: true).update_all(state: 2)
```
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
## Updating to GitLab 13.11
We found an [issue with Git clone/pull through HTTP(s)](https://gitlab.com/gitlab-org/gitlab/-/issues/330787) on Geo secondaries and on any GitLab instance if maintenance mode is enabled. This was caused by a regression in GitLab Workhorse. This is fixed in the [GitLab 13.11.4 patch release](https://about.gitlab.com/releases/2021/05/14/gitlab-13-11-4-released/). To avoid this issue, upgrade to GitLab 13.11.4 or later.
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
+## Updating to GitLab 13.10
+
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
## Updating to GitLab 13.9
+### Error during zero-downtime update: "cannot drop column asset_proxy_whitelist"
+
We've detected an issue [with a column rename](https://gitlab.com/gitlab-org/gitlab/-/issues/324160)
that will prevent upgrades to GitLab 13.9.0, 13.9.1, 13.9.2 and 13.9.3 when following the zero-downtime steps. It is necessary
-to perform the following additional steps for the zero-downtime upgrade:
+to perform the following additional steps for the zero-downtime update:
1. Before running the final `sudo gitlab-rake db:migrate` command on the deploy node,
execute the following queries using the PostgreSQL console (or `sudo gitlab-psql`)
@@ -118,6 +146,10 @@ DETAIL: trigger trigger_0d588df444c8 on table application_settings depends on co
To work around this bug, follow the previous steps to complete the update.
More details are available [in this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/324160).
+### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
+
+GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
+
## Updating to GitLab 13.7
We've detected an issue with the `FetchRemove` call used by Geo secondaries.
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index 3fae004a443..ffc64c4ef70 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -3405,7 +3405,6 @@ Possible values for `when` are:
- `api_failure`: Retry on API failure.
- `stuck_or_timeout_failure`: Retry when the job got stuck or timed out.
- `runner_system_failure`: Retry if there is a runner system failure (for example, job setup failed).
-- `missing_dependency_failure`: Retry if a dependency is missing.
- `runner_unsupported`: Retry if the runner is unsupported.
- `stale_schedule`: Retry if a delayed job could not be executed.
- `job_execution_timeout`: Retry if the script exceeded the maximum execution time set for the job.
diff --git a/doc/development/application_slis/index.md b/doc/development/application_slis/index.md
new file mode 100644
index 00000000000..c1d7ac9fa0c
--- /dev/null
+++ b/doc/development/application_slis/index.md
@@ -0,0 +1,130 @@
+---
+stage: Platforms
+group: Scalability
+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
+---
+
+# GitLab Application Service Level Indicators (SLIs)
+
+> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
+
+It is possible to define [Service Level Indicators
+(SLIs)](https://en.wikipedia.org/wiki/Service_level_indicator)
+directly in the Ruby codebase. This keeps the definition of operations
+and their success close to the implementation and allows the people
+building features to easily define how these features should be
+monitored.
+
+Defining an SLI causes 2
+[Prometheus
+counters](https://prometheus.io/docs/concepts/metric_types/#counter)
+to be emitted from the rails application:
+
+- `gitlab_sli::total`: incremented for each operation.
+- `gitlab_sli::success_total`: incremented for successful
+ operations.
+
+## Existing SLIs
+
+1. [`rails_request_apdex`](rails_request_apdex.md)
+
+## Defining a new SLI
+
+An SLI can be defined using the `Gitlab::Metrics::Sli` class.
+
+Before the first scrape, it is important to have [initialized the SLI
+with all possible
+label-combinations](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics). This
+avoid confusing results when using these counters in calculations.
+
+To initialize an SLI, use the `.inilialize_sli` class method, for
+example:
+
+```ruby
+Gitlab::Metrics::Sli.initialize_sli(:received_email, [
+ {
+ feature_category: :issue_tracking,
+ email_type: :create_issue
+ },
+ {
+ feature_category: :service_desk,
+ email_type: :service_desk
+ },
+ {
+ feature_category: :code_review,
+ email_type: :create_merge_request
+ }
+])
+```
+
+Metrics must be initialized before they get
+scraped for the first time. This could be done at the start time of the
+process that will emit them, in which case we need to pay attention
+not to increase application's boot time too much. This is preferable
+if possible.
+
+Alternatively, if initializing would take too long, this can be done
+during the first scrape. We need to make sure we don't do it for every
+scrape. This can be done as follows:
+
+```ruby
+def initialize_request_slis_if_needed!
+ return if Gitlab::Metrics::Sli.initialized?(:rails_request_apdex)
+ Gitlab::Metrics::Sli.initialize_sli(:rails_request_apdex, possible_request_labels)
+end
+```
+
+Also pay attention to do it for the different metrics
+endpoints we have. Currently the
+[`WebExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/web_exporter.rb)
+and the
+[`HealthController`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/controllers/health_controller.rb)
+for Rails and
+[`SidekiqExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/sidekiq_exporter.rb)
+for Sidekiq.
+
+## Tracking operations for an SLI
+
+Tracking an operation in the newly defined SLI can be done like this:
+
+```ruby
+Gitlab::Metrics::Sli[:received_email].increment(
+ labels: {
+ feature_category: :service_desk,
+ email_type: :service_desk
+ },
+ success: issue_created?
+)
+```
+
+Calling `#increment` on this SLI will increment the total Prometheus counter
+
+```prometheus
+gitlab_sli:received_email:total{ feature_category='service_desk', email_type='service_desk' }
+```
+
+If the `success:` argument passed is truthy, then the success counter
+will also be incremented:
+
+```prometheus
+gitlab_sli:received_email:success_total{ feature_category='service_desk', email_type='service_desk' }
+```
+
+## Using the SLI in service monitoring and alerts
+
+When the application is emitting metrics for the new SLI, those need
+to be consumed in the service catalog to result in alerts, and be
+included in the error budget for stage groups and GitLab.com's overall
+availability.
+
+This is currently being worked on in [this
+project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573). As
+part of [this
+issue](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1307)
+we will update the documentation.
+
+For any question, please don't hesitate to createan issue in [the
+Scalability issue
+tracker](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues)
+or come find us in
+[#g_scalability](https://gitlab.slack.com/archives/CMMF8TKR9) on Slack.
diff --git a/doc/development/application_slis/rails_request_apdex.md b/doc/development/application_slis/rails_request_apdex.md
new file mode 100644
index 00000000000..f235a592b87
--- /dev/null
+++ b/doc/development/application_slis/rails_request_apdex.md
@@ -0,0 +1,230 @@
+---
+stage: Platforms
+group: Scalability
+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
+---
+
+# Rails request apdex SLI
+
+> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
+
+NOTE:
+This SLI is not yet used in [error budgets for stage
+groups](../stage_group_dashboards.md#error-budget) or service
+monitoring. This is being worked on in [this
+project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573).
+
+The request apdex SLI is [an SLI defined in the application](index.md)
+that measures the duration of successful requests as an indicator for
+application performance. This includes the REST and GraphQL API, and the
+regular controller endpoints. It consists of these counters:
+
+1. `gitlab_sli:rails_request_apdex:total`: This counter gets
+ incremented for every request that did not result in a response
+ with a 5xx status code. This means that slow failures don't get
+ counted twice: The request is already counted in the error-SLI.
+
+1. `gitlab_sli:rails_request_apdex:success_total`: This counter gets
+ incremented for every successful request that performed faster than
+ the [defined target duration](#adjusting-request-target-duration).
+
+Both these counters are labeled with:
+
+1. `endpoint_id`: The identification of the Rails Controller or the
+ Grape-API endpoint
+
+1. `feature_category`: The feature category specified for that
+ controller or API endpoint.
+
+## Request Apdex SLO
+
+These counters can be combined into a success ratio, the objective for
+this ratio is defined in the service catalog per service:
+
+1. [Web: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/web.jsonnet#L19)
+1. [API: 0.995](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/api.jsonnet#L19)
+1. [Git: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/git.jsonnet#L22)
+
+This means that for this SLI to meet SLO, the ratio recorded needs to
+be higher than those defined above.
+
+For example: for the web-service, we want at least 99.8% of requests
+to be faster than their target duration.
+
+These are the targets we use for alerting and service montoring. So
+durations should be set keeping those into account.
+
+Both successful measurements and unsuccessful ones have an impact on the
+error budget for stage groups.
+
+## Adjusting request target duration
+
+Not all endpoints perform the same type of work, so it is possible to
+define different durations for different endpoints.
+
+Long-running requests are more expensive for our
+infrastructure: while one request is being served, the thread remains
+occupied for the duration of that request. So nothing else can be handled by that
+thread. Because of Ruby's Global VM Lock, the thread might keep the
+lock and stall other requests handled by the same Puma worker
+process. The request is in fact a noisy neighbor for other requests
+handled by the worker. This is why the upper bound for a target
+duration is capped at 5 seconds.
+
+## Increasing the target duration (setting a slower target)
+
+Increasing the target duration on an existing endpoint can be done on
+a case-by-case basis. Please take the following into account:
+
+1. Apdex is about perceived performance, if a user is actively waiting
+ for the result of a request, waiting 5 seconds might not be
+ acceptable. While if the endpoint is used by an automation
+ requiring a lot of data, 5 seconds could be okay.
+
+ A product manager can help to identify how an endpoint is used.
+
+1. The workload for some endpoints can sometimes differ greatly
+ depending on the parameters specified by the caller. The target
+ duration needs to accomodate that. In some cases, it might be
+ interesting to define a separate [application
+ SLI](index.md#defining-a-new-sli) for what the endpoint is doing.
+
+ When the endpoints in certain cases turn into no-ops, making them
+ very fast, we should ignore these fast requests when setting the
+ target. For example, if the `MergeRequests::DraftsController` is
+ hit for every merge request being viewed, but doesn't need to
+ render anything in most cases, then we should pick the target that
+ would still accomodate the endpoint performing work.
+
+1. Consider the dependent resources consumed by the endpoint. If the endpoint
+ loads a lot of data from Gitaly or the database and this is causing
+ it to not perform satisfactory. It could be better to optimize the
+ way the data is loaded rather than increasing the target duration.
+
+ In cases like this, it might be appropriate to temporarily increase
+ the duration to make the endpoint meet SLO, if this is bearable for
+ the infrastructure. In such cases, please link an issue from a code
+ comment.
+
+ If the endpoint consumes a lot of CPU time, we should also consider
+ this: these kinds of requests are the kind of noisy neighbors we
+ should try to keep as short as possible.
+
+1. Traffic characteristics should also be taken into account: if the
+ trafic to the endpoint is bursty, like CI traffic spinning up a
+ big batch of jobs hitting the same endpoint, then having these
+ endpoints take 5s is not acceptable from an infrastructure point of
+ view. We cannot scale up the fleet fast enough to accomodate for
+ the incoming slow requests alongside the regular traffic.
+
+When increasing the target duration for an existing endpoint, please
+involve a [Scalability team
+member](https://about.gitlab.com/handbook/engineering/infrastructure/team/scalability/#team-members)
+in the review. We can use request rates and durations available in the
+logs to come up with a recommendation. Picking a threshold can be done
+using the same process as for [decreasing a target
+duration](#decreasing-a-target-duration-setting-a-faster-target), picking a duration that is
+higher than the SLO for the service.
+
+We shouldn't set the longest durations on endpoints in the merge
+requests that introduces them, since we don't yet have data to support
+the decision.
+
+## Decreasing a target duration (setting a faster target)
+
+When decreasing the target duration, we need to make sure the endpoint
+still meets SLO for the fleet that handles the request. You can use the
+information in the logs to determine this:
+
+1. Open [this table in
+ Kibana](https://log.gprd.gitlab.net/goto/bbb6465c68eb83642269e64a467df3df)
+
+1. The table loads information for the busiest endpoints by
+ default. You can speed things up by adding a filter for
+ `json.caller_id.keyword` and adding the identifier you're intersted
+ in (for example: `Projects::RawController#show`).
+
+1. Check the [appropriate percentile duration](#request-apdex-slo) for
+ the service the endpoint is handled by. The overall duration should
+ be lower than the target you intend to set.
+
+1. If the overall duration is below the intended targed. Please also
+ check the peaks over time in [this
+ graph](https://log.gprd.gitlab.net/goto/9319c4a402461d204d13f3a4924a89fc)
+ in Kibana. Here, the percentile in question should not peak above
+ the target duration we want to set.
+
+Since decreasing a threshold too much could result in alerts for the
+apdex degradation, please also involve a Scalability team member in
+the merge reqeust.
+
+## How to adjust the target duration
+
+The target duration can be specified similar to how endpoints [get a
+feature category](../feature_categorization/index.md).
+
+For endpoints that don't have a specific target, the default of 1s
+(medium) will be used.
+
+The following configurations are available:
+
+| Name | Duration in seconds | Notes |
+|------------|---------------------|-----------------------------------------------|
+| :very_fast | 0.25s | |
+| :fast | 0.5s | |
+| :medium | 1s | This is the default when nothing is specified |
+| :slow | 5s | |
+
+### Rails controller
+
+A duration can be specified for all actions in a controller like this:
+
+```ruby
+class Boards::ListsController < ApplicationController
+ target_duration :fast
+end
+```
+
+To specify the duration also for certain actions in a controller, they
+can be specified like this:
+
+```ruby
+class Boards::ListsController < ApplicationController
+ target_duration :fast, [:index, :show]
+end
+```
+
+### Grape endpoints
+
+To specify the duration for an entire API class, this can be done as
+follows:
+
+```ruby
+module API
+ class Issues < ::API::Base
+ target_duration :slow
+ end
+end
+```
+
+To specify the duration also for certain actions in a API class, they
+can be specified like this:
+
+```ruby
+module API
+ class Issues < ::API::Base
+ target_duration :fast, [
+ '/groups/:id/issues',
+ '/groups/:id/issues_statistics'
+ ]
+ end
+end
+```
+
+Or, we can specify a custom duration per endpoint:
+
+```ruby
+get 'client/features', target_duration: :fast do
+ # endpoint logic
+end
+```
diff --git a/doc/development/index.md b/doc/development/index.md
index a2ede5ab770..adc5956ea6a 100644
--- a/doc/development/index.md
+++ b/doc/development/index.md
@@ -334,6 +334,7 @@ See [database guidelines](database/index.md).
- [Features inside `.gitlab/`](features_inside_dot_gitlab.md)
- [Dashboards for stage groups](stage_group_dashboards.md)
- [Preventing transient bugs](transient/prevention-patterns.md)
+- [GitLab Application SLIs](application_slis/index.md)
## Other GitLab Development Kit (GDK) guides
diff --git a/doc/development/stage_group_dashboards.md b/doc/development/stage_group_dashboards.md
index 5c1d7b17f0c..a887558e473 100644
--- a/doc/development/stage_group_dashboards.md
+++ b/doc/development/stage_group_dashboards.md
@@ -1,6 +1,6 @@
---
-stage: Enablement
-group: Infrastructure
+stage: Platforms
+group: Scalability
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
---
@@ -58,6 +58,12 @@ component can have 2 indicators:
[Web](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/web.jsonnet#L154)
services, that threshold is **5 seconds**.
+ We're working on making this target configurable per endpoint in [this
+ project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525). Learn
+ how to [customize the request
+ apdex](application_slis/rails_request_apdex.md), this new apdex
+ measurement is not yet part of the error budget.
+
For Sidekiq job execution, the threshold depends on the [job
urgency](sidekiq_style_guide.md#job-urgency). It is
[currently](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/lib/sidekiq-helpers.libsonnet#L25-38)
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 43d6ab2070d..27688c9082a 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -180,6 +180,19 @@ For example:
1. GitLab automatically removes the `priority::low` label, as an issue should not
have two priority labels at the same time.
+### Filter by scoped labels
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12285) in GitLab 14.4.
+
+To filter issue, merge request, or epic lists for ones with labels that belong to a given scope, enter
+`::*` in the searched label name.
+
+For example, filtering by the `platform::*` label returns issues that have `platform::iOS`,
+`platform::Android`, or `platform::Linux` labels.
+
+NOTE:
+This is not available on the [issues or merge requests dashboard pages](../search/index.md#issues-and-merge-requests).
+
### Workflows with scoped labels
Suppose you wanted a custom field in issues to track the operating system platform
diff --git a/lib/banzai/filter/references/abstract_reference_filter.rb b/lib/banzai/filter/references/abstract_reference_filter.rb
index 08014ccdcce..cae0a8b424a 100644
--- a/lib/banzai/filter/references/abstract_reference_filter.rb
+++ b/lib/banzai/filter/references/abstract_reference_filter.rb
@@ -11,7 +11,7 @@ module Banzai
def initialize(doc, context = nil, result = nil)
super
- @reference_cache = ReferenceCache.new(self, context)
+ @reference_cache = ReferenceCache.new(self, context, result)
end
# REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found
diff --git a/lib/banzai/filter/references/reference_cache.rb b/lib/banzai/filter/references/reference_cache.rb
index b2d47aba2d6..259958f1598 100644
--- a/lib/banzai/filter/references/reference_cache.rb
+++ b/lib/banzai/filter/references/reference_cache.rb
@@ -7,9 +7,10 @@ module Banzai
include Gitlab::Utils::StrongMemoize
include RequestStoreReferenceCache
- def initialize(filter, context)
+ def initialize(filter, context, result)
@filter = filter
@context = context
+ @result = result || {}
end
def load_reference_cache(nodes)
@@ -166,7 +167,7 @@ module Banzai
private
- attr_accessor :filter, :context
+ attr_accessor :filter, :context, :result
delegate :project, :group, :parent, :parent_type, to: :filter
@@ -184,7 +185,11 @@ module Banzai
end
def prepare_doc_for_scan(doc)
- html = doc.to_html
+ html = if Feature.enabled?(:reference_cache_memoization, project, default_enabled: :yaml)
+ result[:rendered_html] ||= doc.to_html
+ else
+ doc.to_html
+ end
filter.requires_unescaping? ? unescape_html_entities(html) : html
end
diff --git a/lib/gitlab/ci/build/auto_retry.rb b/lib/gitlab/ci/build/auto_retry.rb
index b98d1d7b330..6ab567dff7c 100644
--- a/lib/gitlab/ci/build/auto_retry.rb
+++ b/lib/gitlab/ci/build/auto_retry.rb
@@ -9,7 +9,8 @@ class Gitlab::Ci::Build::AutoRetry
RETRY_OVERRIDES = {
ci_quota_exceeded: 0,
- no_matching_runner: 0
+ no_matching_runner: 0,
+ missing_dependency_failure: 0
}.freeze
def initialize(build)
diff --git a/lib/gitlab/ci/pipeline/metrics.rb b/lib/gitlab/ci/pipeline/metrics.rb
index 69f17d4bb17..321efa7854f 100644
--- a/lib/gitlab/ci/pipeline/metrics.rb
+++ b/lib/gitlab/ci/pipeline/metrics.rb
@@ -65,13 +65,6 @@ module Gitlab
Gitlab::Metrics.counter(name, comment)
end
- def self.legacy_update_jobs_counter
- name = :ci_legacy_update_jobs_as_retried_total
- comment = 'Counter of occurrences when jobs were not being set as retried before update_retried'
-
- Gitlab::Metrics.counter(name, comment)
- end
-
def self.pipeline_failure_reason_counter
name = :gitlab_ci_pipeline_failure_reasons
comment = 'Counter of pipeline failure reasons'
diff --git a/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb b/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb
index 6cb2e0ddb33..4be4cf62e7b 100644
--- a/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb
+++ b/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb
@@ -80,6 +80,8 @@ module Gitlab
matcher = FindingMatcher.new(head_findings)
base_findings.each do |base_finding|
+ next if base_finding.requires_manual_resolution?
+
matched_head_finding = matcher.find_and_remove_match!(base_finding)
@fixed_findings << base_finding if matched_head_finding.nil?
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 61406b0c5c7..f32e19b726a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6760,6 +6760,9 @@ msgstr ""
msgid "Checkout|Zip code"
msgstr ""
+msgid "Checkout|a storage subscription"
+msgstr ""
+
msgid "Checkout|company or team"
msgstr ""
@@ -9510,9 +9513,6 @@ msgstr ""
msgid "Create a Mattermost team for this group"
msgstr ""
-msgid "Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
-msgstr ""
-
msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies."
msgstr ""
@@ -11160,6 +11160,12 @@ msgstr ""
msgid "Dependency proxy image prefix"
msgstr ""
+msgid "DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
+msgstr ""
+
+msgid "DependencyProxy|Dependency Proxy"
+msgstr ""
+
msgid "DependencyProxy|Toggle Dependency Proxy"
msgstr ""
@@ -17327,7 +17333,7 @@ msgstr[1] ""
msgid "Importing..."
msgstr ""
-msgid "Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct."
+msgid "Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials."
msgstr ""
msgid "Improve customer support with Service Desk"
diff --git a/qa/qa/page/group/settings/package_registries.rb b/qa/qa/page/group/settings/package_registries.rb
index 8a2802b0035..583e63805bf 100644
--- a/qa/qa/page/group/settings/package_registries.rb
+++ b/qa/qa/page/group/settings/package_registries.rb
@@ -7,11 +7,11 @@ module QA
class PackageRegistries < QA::Page::Base
include ::QA::Page::Settings::Common
- view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do
+ view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :package_registry_settings_content
end
- view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do
+ view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :allow_duplicates_toggle
element :allow_duplicates_label
end
diff --git a/scripts/review_apps/automated_cleanup.rb b/scripts/review_apps/automated_cleanup.rb
index 90dc0fd418e..71e95a043e0 100755
--- a/scripts/review_apps/automated_cleanup.rb
+++ b/scripts/review_apps/automated_cleanup.rb
@@ -8,6 +8,10 @@ class AutomatedCleanup
attr_reader :project_path, :gitlab_token
DEPLOYMENTS_PER_PAGE = 100
+ ENVIRONMENT_PREFIX = {
+ review_app: 'review/',
+ docs_review_app: 'review-docs/'
+ }.freeze
IGNORED_HELM_ERRORS = [
'transport is closing',
'error upgrading connection',
@@ -62,13 +66,14 @@ class AutomatedCleanup
releases_to_delete = []
+ # Delete environments via deployments
gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc').auto_paginate do |deployment|
break if Time.parse(deployment.created_at) < deployments_look_back_threshold
environment = deployment.environment
next unless environment
- next unless environment.name.start_with?('review/')
+ next unless environment.name.start_with?(ENVIRONMENT_PREFIX[:review_app])
next if checked_environments.include?(environment.slug)
last_deploy = deployment.created_at
@@ -92,6 +97,10 @@ class AutomatedCleanup
checked_environments << environment.slug
end
+ delete_stopped_environments(environment_type: :review_app, checked_environments: checked_environments, last_updated_threshold: delete_threshold) do |environment|
+ releases_to_delete << Tooling::Helm3Client::Release.new(environment.slug, 1, environment.updated_at, nil, nil, review_apps_namespace)
+ end
+
delete_helm_releases(releases_to_delete)
end
@@ -102,14 +111,12 @@ class AutomatedCleanup
stop_threshold = threshold_time(days: days_for_stop)
delete_threshold = threshold_time(days: days_for_delete)
- max_delete_count = 1000
- delete_count = 0
-
+ # Delete environments via deployments
gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc').auto_paginate do |deployment|
environment = deployment.environment
next unless environment
- next unless environment.name.start_with?('review-docs/')
+ next unless environment.name.start_with?(ENVIRONMENT_PREFIX[:docs_review_app])
next if checked_environments.include?(environment.slug)
last_deploy = deployment.created_at
@@ -120,15 +127,12 @@ class AutomatedCleanup
stop_environment(environment, deployment) if environment_state && environment_state != 'stopped'
end
- if deployed_at < delete_threshold
- delete_environment(environment, deployment)
- delete_count += 1
-
- break if delete_count > max_delete_count
- end
+ delete_environment(environment, deployment) if deployed_at < delete_threshold
checked_environments << environment.slug
end
+
+ delete_stopped_environments(environment_type: :docs_review_app, checked_environments: checked_environments, last_updated_threshold: delete_threshold)
end
def perform_helm_releases_cleanup!(days:)
@@ -171,8 +175,9 @@ class AutomatedCleanup
nil
end
- def delete_environment(environment, deployment)
- print_release_state(subject: 'Review app', release_name: environment.slug, release_date: deployment.created_at, action: 'deleting')
+ def delete_environment(environment, deployment = nil)
+ release_date = deployment ? deployment.created_at : environment.updated_at
+ print_release_state(subject: 'Review app', release_name: environment.slug, release_date: release_date, action: 'deleting')
gitlab.delete_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden
@@ -187,6 +192,24 @@ class AutomatedCleanup
puts "Review app '#{environment.name}' / '#{environment.slug}' (##{environment.id}) is forbidden: skipping it"
end
+ def delete_stopped_environments(environment_type:, checked_environments:, last_updated_threshold:)
+ gitlab.environments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc', states: 'stopped', search: ENVIRONMENT_PREFIX[environment_type]).auto_paginate do |environment|
+ next if skip_environment?(environment: environment, checked_environments: checked_environments, last_updated_threshold: delete_threshold, environment_type: environment_type)
+
+ yield environment if delete_environment(environment)
+
+ checked_environments << environment.slug
+ end
+ end
+
+ def skip_environment?(environment:, checked_environments:, last_updated_threshold:, environment_type:)
+ return true unless environment.name.start_with?(ENVIRONMENT_PREFIX[environment_type])
+ return true if checked_environments.include?(environment.slug)
+ return true if Time.parse(environment.updated_at) > last_updated_threshold
+
+ false
+ end
+
def helm_releases
args = ['--all', '--date']
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb
index e75e661b513..7390edc3c47 100644
--- a/spec/features/dashboard/activity_spec.rb
+++ b/spec/features/dashboard/activity_spec.rb
@@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do
it 'shows Your Projects' do
visit activity_dashboard_path
- expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects')
+ expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Your projects')
end
it 'shows Starred Projects' do
visit activity_dashboard_path(filter: 'starred')
- expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects')
+ expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Starred projects')
end
it 'shows Followed Projects' do
visit activity_dashboard_path(filter: 'followed')
- expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users')
+ expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Followed users')
end
end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index 39f9d3b331b..dacbaa826a0 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do
expect(git_import_instructions).to have_content 'Git repository URL'
end
- it 'reports error if repo URL does not end with .git' do
+ it 'reports error if repo URL is not a valid Git repository' do
+ stub_request(:get, "http://foo/bar/info/refs?service=git-upload-pack").to_return(status: 200, body: "not-a-git-repo")
+
fill_in 'project_import_url', with: 'http://foo/bar'
# simulate blur event
find('body').click
- expect(page).to have_text('A repository URL usually ends in a .git suffix')
+ wait_for_requests
+
+ expect(page).to have_text('There is not a valid Git repository at this URL')
end
it 'keeps "Import project" tab open after form validation error' do
diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
index 2537b8fb816..36b81b3eb28 100644
--- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
+++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
@@ -1,5 +1,6 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import getProjects from '~/analytics/shared/graphql/projects.query.graphql';
@@ -25,6 +26,17 @@ const projects = [
},
];
+const MockGlDropdown = stubComponent(GlDropdown, {
+ template: `
+
+
+
+
+
+
+ `,
+});
+
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
@@ -38,22 +50,32 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => {
let wrapper;
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, stubs = {}) => {
spyQuery = defaultMocks.$apollo.query;
- wrapper = mount(ProjectsDropdownFilter, {
+ wrapper = mountExtended(ProjectsDropdownFilter, {
mocks: { ...defaultMocks },
propsData: {
groupId: 1,
groupNamespace: 'gitlab-org',
...props,
},
+ stubs,
});
};
+ const createWithMockDropdown = (props) => {
+ createComponent(props, { GlDropdown: MockGlDropdown });
+ return wrapper.vm.$nextTick();
+ };
+
afterEach(() => {
wrapper.destroy();
});
+ const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
+ const findHighlightedItemsTitle = () => wrapper.findByText('Selected');
+ const findClearAllButton = () => wrapper.findByText('Clear all');
+
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () =>
@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
- const selectDropdownItemAtIndex = (index) =>
+ const selectDropdownItemAtIndex = (index) => {
findDropdownAtIndex(index).find('button').trigger('click');
+ return wrapper.vm.$nextTick();
+ };
+
+ // NOTE: Selected items are now visually separated from unselected items
+ const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem);
+
+ const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
+ const findSelectedButtonIdentIconAtIndex = (index) =>
+ findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon');
+ const findSelectedButtonAvatarItemAtIndex = (index) =>
+ findSelectedDropdownAtIndex(index).find('img.gl-avatar');
const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => {
});
});
- describe('when passed a an array of defaultProject as prop', () => {
+ describe('highlighted items', () => {
+ const blockDefaultProps = { multiSelect: true };
+ beforeEach(() => {
+ createComponent(blockDefaultProps);
+ });
+
+ describe('with no project selected', () => {
+ it('does not render the highlighted items', async () => {
+ await createWithMockDropdown(blockDefaultProps);
+ expect(findSelectedDropdownItems().length).toBe(0);
+ });
+
+ it('does not render the highlighted items title', () => {
+ expect(findHighlightedItemsTitle().exists()).toBe(false);
+ });
+
+ it('does not render the clear all button', () => {
+ expect(findClearAllButton().exists()).toBe(false);
+ });
+ });
+
+ describe('with a selected project', () => {
+ beforeEach(async () => {
+ await selectDropdownItemAtIndex(0);
+ });
+
+ it('renders the highlighted items', async () => {
+ await createWithMockDropdown(blockDefaultProps);
+ await selectDropdownItemAtIndex(0);
+
+ expect(findSelectedDropdownItems().length).toBe(1);
+ });
+
+ it('renders the highlighted items title', () => {
+ expect(findHighlightedItemsTitle().exists()).toBe(true);
+ });
+
+ it('renders the clear all button', () => {
+ expect(findClearAllButton().exists()).toBe(true);
+ });
+
+ it('clears all selected items when the clear all button is clicked', async () => {
+ await selectDropdownItemAtIndex(1);
+
+ expect(wrapper.text()).toContain('2 projects selected');
+
+ findClearAllButton().trigger('click');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).not.toContain('2 projects selected');
+ expect(wrapper.text()).toContain('Select projects');
+ });
+ });
+ });
+
+ describe('when passed an array of defaultProject as prop', () => {
beforeEach(() => {
createComponent({
defaultProjects: [projects[0]],
@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('when multiSelect is false', () => {
+ const blockDefaultProps = { multiSelect: false };
beforeEach(() => {
- createComponent({ multiSelect: false });
+ createComponent(blockDefaultProps);
});
describe('displays the correct information', () => {
@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
- selectDropdownItemAtIndex(0);
+ await createWithMockDropdown(blockDefaultProps);
+ await selectDropdownItemAtIndex(0);
- await wrapper.vm.$nextTick().then(() => {
- expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
- expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
- });
+ expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
+ expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
- selectDropdownItemAtIndex(1);
+ await createWithMockDropdown(blockDefaultProps);
+ await selectDropdownItemAtIndex(1);
- await wrapper.vm.$nextTick().then(() => {
- expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
- expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
- });
+ expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
+ expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
index f2877a1f2a5..02f9451152f 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
@@ -1,28 +1,20 @@
-import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
+import { GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
+import { nextTick } from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
-import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
+import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
+
import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue';
-import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+
import {
- PACKAGE_SETTINGS_HEADER,
- PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
ERROR_UPDATING_SETTINGS,
SUCCESS_UPDATING_SETTINGS,
} from '~/packages_and_registries/settings/group/constants';
-import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
-import {
- groupPackageSettingsMock,
- groupPackageSettingsMutationMock,
- groupPackageSettingsMutationErrorMock,
-} from '../mock_data';
+import { groupPackageSettingsMock, packageSettings } from '../mock_data';
jest.mock('~/flash');
@@ -39,35 +31,18 @@ describe('Group Settings App', () => {
};
const mountComponent = ({
- provide = defaultProvide,
resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock),
- mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()),
- data = {},
} = {}) => {
localVue.use(VueApollo);
- const requestHandlers = [
- [getGroupPackagesSettingsQuery, resolver],
- [updateNamespacePackageSettings, mutationResolver],
- ];
+ const requestHandlers = [[getGroupPackagesSettingsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
localVue,
apolloProvider,
- provide,
- data() {
- return {
- ...data,
- };
- },
- stubs: {
- GlSprintf,
- SettingsBlock,
- MavenSettings,
- GenericSettings,
- },
+ provide: defaultProvide,
mocks: {
$toast: {
show,
@@ -84,271 +59,73 @@ describe('Group Settings App', () => {
wrapper.destroy();
});
- const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
- const findDescription = () => wrapper.find('[data-testid="description"');
- const findLink = () => wrapper.findComponent(GlLink);
const findAlert = () => wrapper.findComponent(GlAlert);
- const findMavenSettings = () => wrapper.findComponent(MavenSettings);
- const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
- const findGenericSettings = () => wrapper.findComponent(GenericSettings);
- const findGenericDuplicatedSettings = () =>
- findGenericSettings().findComponent(DuplicatesSettings);
+ const findPackageSettings = () => wrapper.findComponent(PackagesSettings);
const waitForApolloQueryAndRender = async () => {
await waitForPromises();
- await wrapper.vm.$nextTick();
+ await nextTick();
};
- const emitSettingsUpdate = (override) => {
- findMavenDuplicatedSettings().vm.$emit('update', {
- mavenDuplicateExceptionRegex: ')',
- ...override,
- });
- };
-
- it('renders a settings block', () => {
- mountComponent();
-
- expect(findSettingsBlock().exists()).toBe(true);
- });
-
- it('passes the correct props to settings block', () => {
- mountComponent();
-
- expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
- });
-
- it('has the correct header text', () => {
- mountComponent();
-
- expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER);
- });
-
- it('has the correct description text', () => {
- mountComponent();
-
- expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
- });
-
- it('has the correct link', () => {
- mountComponent();
-
- expect(findLink().attributes()).toMatchObject({
- href: PACKAGES_DOCS_PATH,
- target: '_blank',
- });
- expect(findLink().text()).toBe('Learn more.');
- });
-
- it('calls the graphql API with the proper variables', () => {
- const resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock);
- mountComponent({ resolver });
-
- expect(resolver).toHaveBeenCalledWith({
- fullPath: defaultProvide.groupPath,
- });
- });
-
- describe('maven settings', () => {
- it('exists', () => {
+ describe.each`
+ finder | entityProp | entityValue
+ ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()}
+ `('settings blocks', ({ finder, entityProp, entityValue }) => {
+ beforeEach(() => {
mountComponent();
-
- expect(findMavenSettings().exists()).toBe(true);
+ return waitForApolloQueryAndRender();
});
- it('assigns duplication allowness and exception props', async () => {
- mountComponent();
+ it('renders the settings block', () => {
+ expect(finder().exists()).toBe(true);
+ });
- expect(findMavenDuplicatedSettings().props('loading')).toBe(true);
-
- await waitForApolloQueryAndRender();
-
- const {
- mavenDuplicatesAllowed,
- mavenDuplicateExceptionRegex,
- } = groupPackageSettingsMock.data.group.packageSettings;
-
- expect(findMavenDuplicatedSettings().props()).toMatchObject({
- duplicatesAllowed: mavenDuplicatesAllowed,
- duplicateExceptionRegex: mavenDuplicateExceptionRegex,
- duplicateExceptionRegexError: '',
- loading: false,
+ it('binds the correctProps', () => {
+ expect(finder().props()).toMatchObject({
+ isLoading: false,
+ [entityProp]: entityValue,
});
});
- it('on update event calls the mutation', async () => {
- const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
- mountComponent({ mutationResolver });
-
- await waitForApolloQueryAndRender();
-
- emitSettingsUpdate();
-
- expect(mutationResolver).toHaveBeenCalledWith({
- input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
- });
- });
- });
-
- describe('generic settings', () => {
- it('exists', () => {
- mountComponent();
-
- expect(findGenericSettings().exists()).toBe(true);
- });
-
- it('assigns duplication allowness and exception props', async () => {
- mountComponent();
-
- expect(findGenericDuplicatedSettings().props('loading')).toBe(true);
-
- await waitForApolloQueryAndRender();
-
- const {
- genericDuplicatesAllowed,
- genericDuplicateExceptionRegex,
- } = groupPackageSettingsMock.data.group.packageSettings;
-
- expect(findGenericDuplicatedSettings().props()).toMatchObject({
- duplicatesAllowed: genericDuplicatesAllowed,
- duplicateExceptionRegex: genericDuplicateExceptionRegex,
- duplicateExceptionRegexError: '',
- loading: false,
- });
- });
-
- it('on update event calls the mutation', async () => {
- const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
- mountComponent({ mutationResolver });
-
- await waitForApolloQueryAndRender();
-
- findMavenDuplicatedSettings().vm.$emit('update', {
- genericDuplicateExceptionRegex: ')',
- });
-
- expect(mutationResolver).toHaveBeenCalledWith({
- input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
- });
- });
- });
-
- describe('settings update', () => {
- describe('success state', () => {
- it('shows a success alert', async () => {
- mountComponent();
-
- await waitForApolloQueryAndRender();
-
- emitSettingsUpdate();
-
- await waitForPromises();
-
+ describe('success event', () => {
+ it('shows a success toast', () => {
+ finder().vm.$emit('success');
expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS);
});
- it('has an optimistic response', async () => {
- const mavenDuplicateExceptionRegex = 'latest[main]something';
- mountComponent();
-
- await waitForApolloQueryAndRender();
-
- expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe('');
-
- emitSettingsUpdate({ mavenDuplicateExceptionRegex });
-
- // wait for apollo to update the model with the optimistic response
- await wrapper.vm.$nextTick();
-
- expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(
- mavenDuplicateExceptionRegex,
- );
-
- // wait for the call to resolve
- await waitForPromises();
-
- expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(
- mavenDuplicateExceptionRegex,
- );
- });
- });
-
- describe('errors', () => {
- const verifyAlert = () => {
- expect(findAlert().exists()).toBe(true);
- expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS);
- expect(findAlert().props('variant')).toBe('warning');
- };
-
- it('mutation payload with root level errors', async () => {
- // note this is a complex test that covers all the path around errors that are shown in the form
- // it's one single it case, due to the expensive preparation and execution
- const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock);
- mountComponent({ mutationResolver });
-
- await waitForApolloQueryAndRender();
-
- emitSettingsUpdate();
-
- await waitForApolloQueryAndRender();
-
- // errors are bound to the component
- expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(
- groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message,
- );
-
- // general error message is shown
-
- verifyAlert();
-
- emitSettingsUpdate();
-
- await wrapper.vm.$nextTick();
-
- // errors are reset on mutation call
- expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe('');
- });
-
- it.each`
- type | mutationResolver
- ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))}
- ${'network'} | ${jest.fn().mockRejectedValue()}
- `('mutation payload with $type error', async ({ mutationResolver }) => {
- mountComponent({ mutationResolver });
-
- await waitForApolloQueryAndRender();
-
- emitSettingsUpdate();
-
- await waitForPromises();
-
- verifyAlert();
- });
-
- it('a successful request dismisses the alert', async () => {
- mountComponent({ data: { alertMessage: 'foo' } });
-
- await waitForApolloQueryAndRender();
+ it('hides the error alert', async () => {
+ finder().vm.$emit('error');
+ await nextTick();
expect(findAlert().exists()).toBe(true);
- emitSettingsUpdate();
-
- await waitForPromises();
+ finder().vm.$emit('success');
+ await nextTick();
expect(findAlert().exists()).toBe(false);
});
+ });
- it('dismiss event from alert dismiss it from the page', async () => {
- mountComponent({ data: { alertMessage: 'foo' } });
+ describe('error event', () => {
+ beforeEach(() => {
+ finder().vm.$emit('error');
+ return nextTick();
+ });
- await waitForApolloQueryAndRender();
+ it('shows an alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+ it('alert has the right text', () => {
+ expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS);
+ });
+
+ it('dismissing the alert removes it', async () => {
expect(findAlert().exists()).toBe(true);
findAlert().vm.$emit('dismiss');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findAlert().exists()).toBe(false);
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
new file mode 100644
index 00000000000..693af21e24a
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
@@ -0,0 +1,277 @@
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
+import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
+import component from '~/packages_and_registries/settings/group/components/packages_settings.vue';
+import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+import {
+ PACKAGE_SETTINGS_HEADER,
+ PACKAGE_SETTINGS_DESCRIPTION,
+ PACKAGES_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+
+import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
+import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import {
+ packageSettings,
+ groupPackageSettingsMock,
+ groupPackageSettingsMutationMock,
+ groupPackageSettingsMutationErrorMock,
+} from '../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
+
+const localVue = createLocalVue();
+
+describe('Packages Settings', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const defaultProvide = {
+ defaultExpanded: false,
+ groupPath: 'foo_group_path',
+ };
+
+ const mountComponent = ({
+ mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()),
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]];
+
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMountExtended(component, {
+ localVue,
+ apolloProvider,
+ provide: defaultProvide,
+ propsData: {
+ packageSettings: packageSettings(),
+ },
+ stubs: {
+ GlSprintf,
+ SettingsBlock,
+ MavenSettings,
+ GenericSettings,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
+ const findDescription = () => wrapper.findByTestId('description');
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findMavenSettings = () => wrapper.findComponent(MavenSettings);
+ const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
+ const findGenericSettings = () => wrapper.findComponent(GenericSettings);
+ const findGenericDuplicatedSettings = () =>
+ findGenericSettings().findComponent(DuplicatesSettings);
+
+ const fillApolloCache = () => {
+ apolloProvider.defaultClient.cache.writeQuery({
+ query: getGroupPackagesSettingsQuery,
+ variables: {
+ fullPath: defaultProvide.groupPath,
+ },
+ ...groupPackageSettingsMock,
+ });
+ };
+
+ const emitMavenSettingsUpdate = (override) => {
+ findMavenDuplicatedSettings().vm.$emit('update', {
+ mavenDuplicateExceptionRegex: ')',
+ ...override,
+ });
+ };
+
+ it('renders a settings block', () => {
+ mountComponent();
+
+ expect(findSettingsBlock().exists()).toBe(true);
+ });
+
+ it('passes the correct props to settings block', () => {
+ mountComponent();
+
+ expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
+ });
+
+ it('has the correct header text', () => {
+ mountComponent();
+
+ expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER);
+ });
+
+ it('has the correct description text', () => {
+ mountComponent();
+
+ expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
+ });
+
+ it('has the correct link', () => {
+ mountComponent();
+
+ expect(findLink().attributes()).toMatchObject({
+ href: PACKAGES_DOCS_PATH,
+ target: '_blank',
+ });
+ expect(findLink().text()).toBe('Learn more.');
+ });
+
+ describe('maven settings', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findMavenSettings().exists()).toBe(true);
+ });
+
+ it('assigns duplication allowness and exception props', async () => {
+ mountComponent();
+
+ const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings();
+
+ expect(findMavenDuplicatedSettings().props()).toMatchObject({
+ duplicatesAllowed: mavenDuplicatesAllowed,
+ duplicateExceptionRegex: mavenDuplicateExceptionRegex,
+ duplicateExceptionRegexError: '',
+ loading: false,
+ });
+ });
+
+ it('on update event calls the mutation', () => {
+ const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
+ mountComponent({ mutationResolver });
+
+ fillApolloCache();
+
+ emitMavenSettingsUpdate();
+
+ expect(mutationResolver).toHaveBeenCalledWith({
+ input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
+ });
+ });
+ });
+
+ describe('generic settings', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findGenericSettings().exists()).toBe(true);
+ });
+
+ it('assigns duplication allowness and exception props', async () => {
+ mountComponent();
+
+ const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings();
+
+ expect(findGenericDuplicatedSettings().props()).toMatchObject({
+ duplicatesAllowed: genericDuplicatesAllowed,
+ duplicateExceptionRegex: genericDuplicateExceptionRegex,
+ duplicateExceptionRegexError: '',
+ loading: false,
+ });
+ });
+
+ it('on update event calls the mutation', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
+ mountComponent({ mutationResolver });
+
+ fillApolloCache();
+
+ findMavenDuplicatedSettings().vm.$emit('update', {
+ genericDuplicateExceptionRegex: ')',
+ });
+
+ expect(mutationResolver).toHaveBeenCalledWith({
+ input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
+ });
+ });
+ });
+
+ describe('settings update', () => {
+ describe('success state', () => {
+ it('emits a success event', async () => {
+ mountComponent();
+
+ fillApolloCache();
+ emitMavenSettingsUpdate();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('success')).toEqual([[]]);
+ });
+
+ it('has an optimistic response', () => {
+ const mavenDuplicateExceptionRegex = 'latest[main]something';
+ mountComponent();
+
+ fillApolloCache();
+
+ expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe('');
+
+ emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex });
+
+ expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({
+ ...packageSettings(),
+ mavenDuplicateExceptionRegex,
+ });
+ });
+ });
+
+ describe('errors', () => {
+ it('mutation payload with root level errors', async () => {
+ // note this is a complex test that covers all the path around errors that are shown in the form
+ // it's one single it case, due to the expensive preparation and execution
+ const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock);
+ mountComponent({ mutationResolver });
+
+ fillApolloCache();
+
+ emitMavenSettingsUpdate();
+
+ await waitForPromises();
+
+ // errors are bound to the component
+ expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(
+ groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message,
+ );
+
+ // general error message is shown
+
+ expect(wrapper.emitted('error')).toEqual([[]]);
+
+ emitMavenSettingsUpdate();
+
+ await wrapper.vm.$nextTick();
+
+ // errors are reset on mutation call
+ expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe('');
+ });
+
+ it.each`
+ type | mutationResolver
+ ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))}
+ ${'network'} | ${jest.fn().mockRejectedValue()}
+ `('mutation payload with $type error', async ({ mutationResolver }) => {
+ mountComponent({ mutationResolver });
+
+ fillApolloCache();
+ emitMavenSettingsUpdate();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[]]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js
index 65119e288a1..917dbfddd64 100644
--- a/spec/frontend/packages_and_registries/settings/group/mock_data.js
+++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js
@@ -1,12 +1,15 @@
+export const packageSettings = () => ({
+ mavenDuplicatesAllowed: true,
+ mavenDuplicateExceptionRegex: '',
+ genericDuplicatesAllowed: true,
+ genericDuplicateExceptionRegex: '',
+});
+
export const groupPackageSettingsMock = {
data: {
group: {
- packageSettings: {
- mavenDuplicatesAllowed: true,
- mavenDuplicateExceptionRegex: '',
- genericDuplicatesAllowed: true,
- genericDuplicateExceptionRegex: '',
- },
+ fullPath: 'foo_group_path',
+ packageSettings: packageSettings(),
},
},
};
diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb
index 60ff15a88e0..e703bbc4927 100644
--- a/spec/lib/banzai/cross_project_reference_spec.rb
+++ b/spec/lib/banzai/cross_project_reference_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Banzai::CrossProjectReference do
let(:including_class) { Class.new.include(described_class).new }
- let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {})}
+ let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {}, {})}
before do
allow(including_class).to receive(:context).and_return({})
diff --git a/spec/lib/banzai/filter/references/reference_cache_spec.rb b/spec/lib/banzai/filter/references/reference_cache_spec.rb
index c9404c381d3..dcd153da16a 100644
--- a/spec/lib/banzai/filter/references/reference_cache_spec.rb
+++ b/spec/lib/banzai/filter/references/reference_cache_spec.rb
@@ -12,15 +12,48 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do
let(:filter_class) { Banzai::Filter::References::IssueReferenceFilter }
let(:filter) { filter_class.new(doc, project: project) }
- let(:cache) { described_class.new(filter, { project: project }) }
+ let(:cache) { described_class.new(filter, { project: project }, result) }
+ let(:result) { {} }
describe '#load_references_per_parent' do
+ subject { cache.load_references_per_parent(filter.nodes) }
+
it 'loads references grouped per parent paths' do
- cache.load_references_per_parent(filter.nodes)
+ expect(doc).to receive(:to_html).and_call_original
+
+ subject
expect(cache.references_per_parent).to eq({ project.full_path => [issue1.iid, issue2.iid].to_set,
project2.full_path => [issue3.iid].to_set })
end
+
+ context 'when rendered_html is memoized' do
+ let(:result) { { rendered_html: 'html' } }
+
+ it 'reuses memoized rendered HTML when available' do
+ expect(doc).not_to receive(:to_html)
+
+ subject
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(reference_cache_memoization: false)
+ end
+
+ it 'ignores memoized rendered HTML' do
+ expect(doc).to receive(:to_html).and_call_original
+
+ subject
+ end
+ end
+ end
+
+ context 'when result is not available' do
+ let(:result) { nil }
+
+ it { expect { subject }.not_to raise_error }
+ end
end
describe '#load_parent_per_reference' do
@@ -47,7 +80,7 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do
it 'does not have an N+1 query problem with cross projects' do
doc_single = Nokogiri::HTML.fragment("#1")
filter_single = filter_class.new(doc_single, project: project)
- cache_single = described_class.new(filter_single, { project: project })
+ cache_single = described_class.new(filter_single, { project: project }, {})
control_count = ActiveRecord::QueryRecorder.new do
cache_single.load_references_per_parent(filter_single.nodes)
diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb
index e83e1326206..fc5999d59ac 100644
--- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb
+++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do
"default for scheduler failure" | 1 | {} | :scheduler_failure | true
"quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false
"no matching runner" | 0 | { max: 2 } | :no_matching_runner | false
+ "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false
end
with_them do
diff --git a/spec/lib/gitlab/ci/config/entry/retry_spec.rb b/spec/lib/gitlab/ci/config/entry/retry_spec.rb
index b38387a437e..84ef5344a8b 100644
--- a/spec/lib/gitlab/ci/config/entry/retry_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/retry_spec.rb
@@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do
api_failure
stuck_or_timeout_failure
runner_system_failure
- missing_dependency_failure
runner_unsupported
stale_schedule
job_execution_timeout
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 14c7f7f227b..a19acbd196d 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1221,32 +1221,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
%w(test success),
%w(deploy running)])
end
-
- context 'when commit status is retried' do
- let!(:old_commit_status) do
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'mac',
- stage_idx: 0,
- status: 'success')
- end
-
- context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
- before do
- stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
-
- Ci::ProcessPipelineService
- .new(pipeline)
- .execute
- end
-
- it 'ignores the previous state' do
- expect(statuses).to eq([%w(build success),
- %w(test success),
- %w(deploy running)])
- end
- end
- end
end
context 'when there is a stage with warnings' do
diff --git a/spec/models/concerns/vulnerability_finding_helpers_spec.rb b/spec/models/concerns/vulnerability_finding_helpers_spec.rb
new file mode 100644
index 00000000000..023ecccb520
--- /dev/null
+++ b/spec/models/concerns/vulnerability_finding_helpers_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe VulnerabilityFindingHelpers do
+ let(:cls) do
+ Class.new do
+ include VulnerabilityFindingHelpers
+
+ attr_accessor :report_type
+
+ def initialize(report_type)
+ @report_type = report_type
+ end
+ end
+ end
+
+ describe '#requires_manual_resolution?' do
+ it 'returns false if the finding does not require manual resolution' do
+ expect(cls.new('sast').requires_manual_resolution?).to eq(false)
+ end
+
+ it 'returns true when the finding requires manual resolution' do
+ expect(cls.new('secret_detection').requires_manual_resolution?).to eq(true)
+ end
+ end
+end
diff --git a/spec/requests/import/url_controller_spec.rb b/spec/requests/import/url_controller_spec.rb
new file mode 100644
index 00000000000..63af5e8b469
--- /dev/null
+++ b/spec/requests/import/url_controller_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::UrlController do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ login_as(user)
+ end
+
+ describe 'POST #validate' do
+ it 'reports success when service reports success status' do
+ allow_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
+ allow(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ post import_url_validate_path, params: { url: 'https://fake.repo' }
+
+ expect(json_response).to eq({ 'success' => true })
+ end
+
+ it 'exposes error message when service reports error' do
+ expect_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
+ expect(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.error(message: 'foobar'))
+ end
+
+ post import_url_validate_path, params: { url: 'https://fake.repo' }
+
+ expect(json_response).to eq({ 'success' => false, 'message' => 'foobar' })
+ end
+
+ context 'with an anonymous user' do
+ before do
+ sign_out(user)
+ end
+
+ it 'redirects to sign-in page' do
+ post import_url_validate_path
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index b5bf0adadaf..404e1bf7c87 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -10,11 +10,9 @@ RSpec.describe Ci::ProcessPipelineService do
end
let(:pipeline_processing_events_counter) { double(increment: true) }
- let(:legacy_update_jobs_counter) { double(increment: true) }
let(:metrics) do
- double(pipeline_processing_events_counter: pipeline_processing_events_counter,
- legacy_update_jobs_counter: legacy_update_jobs_counter)
+ double(pipeline_processing_events_counter: pipeline_processing_events_counter)
end
subject { described_class.new(pipeline) }
@@ -33,68 +31,4 @@ RSpec.describe Ci::ProcessPipelineService do
subject.execute
end
end
-
- describe 'updating a list of retried builds' do
- let!(:build_retried) { create_build('build') }
- let!(:build) { create_build('build') }
- let!(:test) { create_build('test') }
-
- context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do
- it 'does not update older builds as retried' do
- subject.execute
-
- expect(all_builds.latest).to contain_exactly(build, build_retried, test)
- expect(all_builds.retried).to be_empty
- end
- end
-
- context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
- before do
- stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
- end
-
- it 'returns unique statuses' do
- subject.execute
-
- expect(all_builds.latest).to contain_exactly(build, test)
- expect(all_builds.retried).to contain_exactly(build_retried)
- end
-
- it 'increments the counter' do
- expect(legacy_update_jobs_counter).to receive(:increment)
-
- subject.execute
- end
-
- it 'logs the project and pipeline id' do
- expect(Gitlab::AppJsonLogger).to receive(:info).with(event: 'update_retried_is_used',
- project_id: project.id,
- pipeline_id: pipeline.id)
-
- subject.execute
- end
-
- context 'when the previous build has already retried column true' do
- before do
- build_retried.update_columns(retried: true)
- end
-
- it 'does not increment the counter' do
- expect(legacy_update_jobs_counter).not_to receive(:increment)
-
- subject.execute
- end
- end
- end
-
- private
-
- def create_build(name, **opts)
- create(:ci_build, :created, pipeline: pipeline, name: name, **opts)
- end
-
- def all_builds
- pipeline.builds.order(:stage_idx, :id)
- end
- end
end
diff --git a/spec/services/ci/stuck_builds/drop_running_service_spec.rb b/spec/services/ci/stuck_builds/drop_running_service_spec.rb
index d2132914a02..439eb3b7724 100644
--- a/spec/services/ci/stuck_builds/drop_running_service_spec.rb
+++ b/spec/services/ci/stuck_builds/drop_running_service_spec.rb
@@ -17,20 +17,47 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
job.update!(job_attributes)
end
- context 'when job is running' do
- let(:status) { 'running' }
+ around do |example|
+ freeze_time { example.run }
+ end
- context 'when job was updated_at more than an hour ago' do
- let(:updated_at) { 2.hours.ago }
+ shared_examples 'running builds' do
+ context 'when job is running' do
+ let(:status) { 'running' }
+ let(:outdated_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago - 30.minutes }
+ let(:fresh_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago + 30.minutes }
- it_behaves_like 'job is dropped'
+ context 'when job is outdated' do
+ let(:created_at) { outdated_time }
+ let(:updated_at) { outdated_time }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when job is fresh' do
+ let(:created_at) { fresh_time }
+ let(:updated_at) { fresh_time }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when job freshly updated' do
+ let(:created_at) { outdated_time }
+ let(:updated_at) { fresh_time }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+ end
+
+ include_examples 'running builds'
+
+ context 'when ci_new_query_for_running_stuck_jobs flag is disabled' do
+ before do
+ stub_feature_flags(ci_new_query_for_running_stuck_jobs: false)
end
- context 'when job was updated in less than 1 hour ago' do
- let(:updated_at) { 30.minutes.ago }
-
- it_behaves_like 'job is unchanged'
- end
+ include_examples 'running builds'
end
%w(success skipped failed canceled scheduled pending).each do |status|
@@ -51,15 +78,4 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
end
end
end
-
- context 'for deleted project' do
- let(:status) { 'running' }
- let(:updated_at) { 2.days.ago }
-
- before do
- job.project.update!(pending_delete: true)
- end
-
- it_behaves_like 'job is dropped'
- end
end
diff --git a/spec/services/import/validate_remote_git_endpoint_service_spec.rb b/spec/services/import/validate_remote_git_endpoint_service_spec.rb
new file mode 100644
index 00000000000..97c8a9f5dd4
--- /dev/null
+++ b/spec/services/import/validate_remote_git_endpoint_service_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::ValidateRemoteGitEndpointService do
+ include StubRequests
+
+ let_it_be(:base_url) { 'http://demo.host/path' }
+ let_it_be(:endpoint_url) { "#{base_url}/info/refs?service=git-upload-pack" }
+ let_it_be(:error_message) { "#{base_url} is not a valid HTTP Git repository" }
+
+ describe '#execute' do
+ let(:valid_response) do
+ { status: 200,
+ body: '001e# service=git-upload-pack',
+ headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } }
+ end
+
+ it 'correctly handles URLs with fragment' do
+ allow(Gitlab::HTTP).to receive(:get)
+
+ described_class.new(url: "#{base_url}#somehash").execute
+
+ expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false)
+ end
+
+ context 'when receiving HTTP response' do
+ subject { described_class.new(url: base_url) }
+
+ it 'returns success when HTTP response is valid and contains correct payload' do
+ stub_full_request(endpoint_url, method: :get).to_return(valid_response)
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.success?).to be(true)
+ end
+
+ it 'reports error when status code is not 200' do
+ stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ status: 301 }))
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq(error_message)
+ end
+
+ it 'reports error when required header is missing' do
+ stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ headers: nil }))
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq(error_message)
+ end
+
+ it 'reports error when body is in invalid format' do
+ stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ body: 'invalid content' }))
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq(error_message)
+ end
+
+ it 'reports error when exception is raised' do
+ stub_full_request(endpoint_url, method: :get).to_raise(SocketError.new('dummy message'))
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq(error_message)
+ end
+ end
+
+ it 'passes basic auth when credentials are provided' do
+ allow(Gitlab::HTTP).to receive(:get)
+
+ described_class.new(url: "#{base_url}#somehash", user: 'user', password: 'password').execute
+
+ expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: { username: 'user', password: 'password' }, stream_body: true, follow_redirects: false)
+ end
+ end
+end
diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb
index 10068b9c508..b6cf78b9046 100644
--- a/spec/support/helpers/filtered_search_helpers.rb
+++ b/spec/support/helpers/filtered_search_helpers.rb
@@ -101,6 +101,27 @@ module FilteredSearchHelpers
end
end
+ # Same as `expect_tokens` but works with GlFilteredSearch
+ def expect_vue_tokens(tokens)
+ page.within '.gl-search-box-by-click .gl-filtered-search-scrollable' do
+ token_elements = page.all(:css, '.gl-filtered-search-token')
+
+ tokens.each_with_index do |token, index|
+ el = token_elements[index]
+
+ expect(el.find('.gl-filtered-search-token-type')).to have_content(token[:name])
+ expect(el.find('.gl-filtered-search-token-operator')).to have_content(token[:operator]) if token[:operator].present?
+ expect(el.find('.gl-filtered-search-token-data')).to have_content(token[:value]) if token[:value].present?
+
+ # gl-emoji content is blank when the emoji unicode is not supported
+ if token[:emoji_name].present?
+ selector = %(gl-emoji[data-name="#{token[:emoji_name]}"])
+ expect(el.find('.gl-filtered-search-token-data-content')).to have_css(selector)
+ end
+ end
+ end
+ end
+
def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=')
{ name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" }
end
diff --git a/tooling/lib/tooling/helm3_client.rb b/tooling/lib/tooling/helm3_client.rb
index 3743138f27e..6e4a35e82f1 100644
--- a/tooling/lib/tooling/helm3_client.rb
+++ b/tooling/lib/tooling/helm3_client.rb
@@ -19,7 +19,7 @@ module Tooling
end
def last_update
- @last_update ||= Time.parse(self[:last_update])
+ @last_update ||= self[:last_update] ? Time.parse(self[:last_update]) : nil
end
end