Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-01-25 12:07:45 +00:00
parent 06b4bed158
commit 7b1fa4c1a1
75 changed files with 1369 additions and 1061 deletions

View File

@ -55,7 +55,12 @@ include:
GEO_SECONDARY_PROXY: 0
RSPEC_TESTS_FILTER_FILE: "${RSPEC_MATCHING_TESTS_PATH}"
SUCCESSFULLY_RETRIED_TEST_EXIT_CODE: 137
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets", "detect-tests"]
needs:
- job: "setup-test-env"
- job: "retrieve-tests-metadata"
- job: "compile-test-assets"
- job: "detect-tests"
optional: true
script:
- !reference [.base-script, script]
# We need to exclude background migration because unit tests run with

View File

@ -1550,9 +1550,9 @@
.rails:rules:detect-tests:
rules:
- <<: *if-merge-request-labels-run-all-rspec
- <<: *if-default-refs
- <<: *if-merge-request
changes: *code-backstage-qa-patterns
- <<: *if-default-refs
- <<: *if-merge-request
changes: *workhorse-patterns
.rails:rules:detect-previous-failed-tests:

View File

@ -17,6 +17,7 @@ import {
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
allEnvironments,
@ -64,7 +65,7 @@ export default {
GlModal,
GlSprintf,
},
mixins: [trackingMixin],
mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: [
'awsLogoSvgPath',
'awsTipCommandsLink',
@ -74,6 +75,7 @@ export default {
'environmentScopeLink',
'isProtectedByDefault',
'maskedEnvironmentVariablesLink',
'maskableRawRegex',
'maskableRegex',
'protectedEnvironmentVariablesLink',
],
@ -121,7 +123,7 @@ export default {
},
computed: {
canMask() {
const regex = RegExp(this.maskableRegex);
const regex = RegExp(this.useRawMaskableRegexp ? this.maskableRawRegex : this.maskableRegex);
return regex.test(this.variable.value);
},
canSubmit() {
@ -134,11 +136,17 @@ export default {
displayMaskedError() {
return !this.canMask && this.variable.masked;
},
isUsingRawRegexFlag() {
return this.glFeatures.ciRemoveCharacterLimitationRawMaskedVar;
},
isEditing() {
return this.mode === EDIT_VARIABLE_ACTION;
},
isExpanded() {
return !this.variable.raw;
return !this.isRaw;
},
isRaw() {
return this.variable.raw;
},
isTipVisible() {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
@ -174,6 +182,9 @@ export default {
return true;
},
useRawMaskableRegexp() {
return this.isRaw && this.isUsingRawRegexFlag;
},
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
@ -315,11 +326,7 @@ export default {
class="gl-font-monospace!"
spellcheck="false"
/>
<p
v-if="variable.raw"
class="gl-mt-2 gl-mb-0 text-secondary"
data-testid="raw-variable-tip"
>
<p v-if="isRaw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip">
{{ __('Variable value will be evaluated as raw string.') }}
</p>
</gl-form-group>

View File

@ -21,6 +21,7 @@ const mountCiVariableListApp = (containerEl) => {
isGroup,
isProject,
maskedEnvironmentVariablesLink,
maskableRawRegex,
maskableRegex,
projectFullPath,
projectId,
@ -63,6 +64,7 @@ const mountCiVariableListApp = (containerEl) => {
isProject: parsedIsProject,
isProtectedByDefault,
maskedEnvironmentVariablesLink,
maskableRawRegex,
maskableRegex,
projectFullPath,
projectId,

View File

@ -1,8 +1,16 @@
<script>
import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
LIST_KEY_CREATED_AT,
BASE_SORT_FIELDS,
METRIC_KEY_PREFIX,
} from '~/ml/experiment_tracking/constants';
import { s__ } from '~/locale';
import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import IncubationAlert from './incubation_alert.vue';
export default {
@ -13,18 +21,36 @@ export default {
TimeAgo,
IncubationAlert,
GlPagination,
RegistrySearch,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['candidates', 'metricNames', 'paramNames', 'pagination'],
data() {
const query = queryToObject(window.location.search);
const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
if (query.orderByType === 'metric') {
orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
}
return {
page: parseInt(getParameterValues('page')[0], 10) || 1,
page: parseInt(query.page, 10) || 1,
filters: filter,
sorting: {
orderBy,
sort: (query.sort || 'desc').toLowerCase(),
},
};
},
computed: {
fields() {
if (this.candidates.length === 0) return [];
return [
{ key: 'name', label: this.$options.i18n.nameLabel },
{ key: 'created_at', label: this.$options.i18n.createdAtLabel },
@ -44,21 +70,63 @@ export default {
nextPage() {
return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
},
sortableFields() {
return [
...BASE_SORT_FIELDS,
...this.metricNames.map((name) => ({
orderBy: `${METRIC_KEY_PREFIX}${name}`,
label: capitalizeFirstCharacter(name),
})),
];
},
parsedQuery() {
const name = this.filters
.map((f) => f.value.data)
.join(' ')
.trim();
const filterByQuery = name === '' ? {} : { name };
let orderByType = 'column';
let { orderBy } = this.sorting;
const { sort } = this.sorting;
if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
orderByType = 'metric';
}
return { ...filterByQuery, orderBy, orderByType, sort };
},
},
methods: {
generateLink(page) {
return setUrlParams({ page });
return setUrlParams({ ...this.parsedQuery, page });
},
submitFilters() {
return visitUrl(setUrlParams({ ...this.parsedQuery, page: this.page }));
},
updateFilters(newValue) {
this.filters = newValue;
},
updateSorting(newValue) {
this.sorting = { ...this.sorting, ...newValue };
},
updateSortingAndEmitUpdate(newValue) {
this.updateSorting(newValue);
this.submitFilters();
},
},
i18n: {
titleLabel: __('Experiment candidates'),
emptyStateLabel: __('This experiment has no logged candidates'),
artifactsLabel: __('Artifacts'),
detailsLabel: __('Details'),
userLabel: __('User'),
createdAtLabel: __('Created at'),
nameLabel: __('Name'),
noDataContent: __('-'),
titleLabel: s__('MlExperimentTracking|Experiment candidates'),
emptyStateLabel: s__('MlExperimentTracking|No candidates to display'),
artifactsLabel: s__('MlExperimentTracking|Artifacts'),
detailsLabel: s__('MlExperimentTracking|Details'),
userLabel: s__('MlExperimentTracking|User'),
createdAtLabel: s__('MlExperimentTracking|Created at'),
nameLabel: s__('MlExperimentTracking|Name'),
noDataContent: s__('MlExperimentTracking|-'),
filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'),
},
};
</script>
@ -71,6 +139,16 @@ export default {
{{ $options.i18n.titleLabel }}
</h3>
<registry-search
:filters="filters"
:sorting="sorting"
:sortable-fields="sortableFields"
@sorting:changed="updateSortingAndEmitUpdate"
@filter:changed="updateFilters"
@filter:submit="submitFilters"
@filter:clear="filters = []"
/>
<gl-table
:fields="fields"
:items="candidates"

View File

@ -0,0 +1,16 @@
import { __ } from '~/locale';
export const METRIC_KEY_PREFIX = 'metric.';
export const LIST_KEY_CREATED_AT = 'created_at';
export const BASE_SORT_FIELDS = Object.freeze([
{
orderBy: 'name',
label: __('Name'),
},
{
orderBy: LIST_KEY_CREATED_AT,
label: __('Created at'),
},
]);

View File

@ -13,6 +13,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :disable_query_limiting, only: [:usage_data]
before_action do
push_frontend_feature_flag(:ci_remove_character_limitation_raw_masked_var, type: :development)
end
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
:general, :reporting, :metrics_and_profiling, :network,
:preferences, :update, :reset_health_check_token

View File

@ -2,7 +2,6 @@
module IssuableCollectionsAction
extend ActiveSupport::Concern
include GracefulTimeoutHandling
include IssuableCollections
include IssuesCalendar

View File

@ -11,6 +11,10 @@ module Groups
before_action :push_licensed_features, only: [:show]
before_action :assign_variables_to_gon, only: [:show]
before_action do
push_frontend_feature_flag(:ci_remove_character_limitation_raw_masked_var, type: :development)
end
feature_category :continuous_integration
urgency :low

View File

@ -3,6 +3,8 @@
module Projects
module Ml
class ExperimentsController < ::Projects::ApplicationController
include Projects::Ml::ExperimentsHelper
before_action :check_feature_flag
feature_category :mlops
@ -19,24 +21,19 @@ module Projects
return redirect_to project_ml_experiments_path(@project) unless @experiment.present?
page = params[:page].to_i
page = 1 if page == 0
find_params = params
.transform_keys(&:underscore)
.permit(:name, :order_by, :sort, :order_by_type)
@candidates = @experiment.candidates
.including_relationships
.page(page)
.per(MAX_CANDIDATES_PER_PAGE)
@candidates = CandidateFinder.new(@experiment, find_params).execute
return unless @candidates
page = [params[:page].to_i, 1].max
return redirect_to(url_for(page: @candidates.total_pages)) if @candidates.out_of_range?
@candidates, @pagination_info = paginate_candidates(@candidates, page, MAX_CANDIDATES_PER_PAGE)
@pagination = {
page: page,
is_last_page: @candidates.last_page?,
per_page: MAX_CANDIDATES_PER_PAGE,
total_items: @candidates.total_count
}
return if @pagination_info[:total_pages] == 0
redirect_to(url_for(safe_params.merge(page: @pagination_info[:total_pages]))) if @pagination_info[:out_of_range]
@candidates.each(&:artifact_lazy)
end

View File

@ -12,6 +12,10 @@ module Projects
before_action :check_builds_available!
before_action :define_variables
before_action do
push_frontend_feature_flag(:ci_remove_character_limitation_raw_masked_var, type: :development)
end
helper_method :highlight_badge
feature_category :continuous_integration

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Projects
module Ml
class CandidateFinder
VALID_ORDER_BY_TYPES = %w[column metric].freeze
VALID_ORDER_BY_COLUMNS = %w[name created_at id].freeze
VALID_SORT = %w[asc desc].freeze
def initialize(experiment, params = {})
@experiment = experiment
@params = params
end
def execute
candidates = @experiment.candidates.including_relationships
candidates = by_name(candidates)
order(candidates)
end
private
def by_name(candidates)
return candidates unless @params[:name].present?
candidates.by_name(@params[:name])
end
def order(candidates)
return candidates.order_by_metric(metric_order_by, sort) if order_by_metric?
candidates.order_by("#{column_order_by}_#{sort}").with_order_id_desc
end
def order_by_metric?
order_by_type == 'metric'
end
def order_by_type
valid_or_default(@params[:order_by_type], VALID_ORDER_BY_TYPES, 'column')
end
def column_order_by
valid_or_default(@params[:order_by], VALID_ORDER_BY_COLUMNS, 'created_at')
end
def metric_order_by
@params[:order_by] || ''
end
def sort
valid_or_default(@params[:sort]&.downcase, VALID_SORT, 'desc')
end
def valid_or_default(value, valid_values, default)
return value if valid_values.include?(value)
default
end
end
end
end

View File

@ -47,6 +47,10 @@ module Ci
]
end
def ci_variable_maskable_raw_regex
Ci::Maskable::MASK_AND_RAW_REGEX.inspect.sub('\\A', '^').sub('\\z', '$')[1...-1]
end
def ci_variable_maskable_regex
Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(%r{^/}, '').sub(%r{/[a-z]*$}, '').gsub('\/', '/')
end

View File

@ -42,6 +42,23 @@ module Projects
Gitlab::Json.generate(data)
end
def paginate_candidates(candidates, page, max_per_page)
return [candidates, nil] unless candidates
candidates = candidates.page(page).per(max_per_page)
pagination_info = {
page: page,
is_last_page: candidates.last_page?,
per_page: max_per_page,
total_items: candidates.total_count,
total_pages: candidates.total_pages,
out_of_range: candidates.out_of_range?
}
[candidates, pagination_info]
end
private
def link_to_artifact(candidate)

View File

@ -12,10 +12,30 @@ module Ci
# * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~'
# * Absolutely no fun is allowed
REGEX = %r{\A[a-zA-Z0-9_+=/@:.~-]{8,}\z}.freeze
# * Single line
# * No spaces
# * Minimal length of 8 characters
# * Some fun is allowed
MASK_AND_RAW_REGEX = %r{\A\S{8,}\z}.freeze
included do
validates :masked, inclusion: { in: [true, false] }
validates :value, format: { with: REGEX }, if: :masked?
validates :value, format: { with: REGEX }, if: :masked_and_expanded?
validates :value, format: { with: MASK_AND_RAW_REGEX }, if: :masked_and_raw?
end
def masked_and_raw?
return false unless Feature.enabled?(:ci_remove_character_limitation_raw_masked_var)
return false unless self.class.method_defined?(:raw)
masked? && raw?
end
def masked_and_expanded?
return true unless Feature.enabled?(:ci_remove_character_limitation_raw_masked_var)
return true unless self.class.method_defined?(:raw)
masked? && !raw?
end
def to_runner_variable

View File

@ -4,8 +4,14 @@ module IncidentManagement
class TimelineEventTag < ApplicationRecord
self.table_name = 'incident_management_timeline_event_tags'
START_TIME_TAG_NAME = 'Start time'
END_TIME_TAG_NAME = 'End time'
PREDEFINED_TAGS = [
'Start time',
'End time',
'Impact detected',
'Response initiated',
'Impact mitigated',
'Cause identified'
].freeze
belongs_to :project, inverse_of: :incident_management_timeline_event_tags

View File

@ -2,6 +2,8 @@
module Ml
class Candidate < ApplicationRecord
include Sortable
PACKAGE_PREFIX = 'ml_candidate_'
enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 }
@ -19,6 +21,12 @@ module Ml
attribute :iid, default: -> { SecureRandom.uuid }
scope :including_relationships, -> { includes(:latest_metrics, :params, :user) }
scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection
scope :order_by_metric, ->(metric, direction) do
subquery = Ml::CandidateMetric.latest.where(name: metric)
joins("INNER JOIN (#{subquery.to_sql}) latest ON latest.candidate_id = ml_candidates.id")
.order("latest.value #{direction}, ml_candidates.id DESC")
end
delegate :project_id, :project, to: :experiment

View File

@ -101,7 +101,7 @@ class User < ApplicationRecord
MINIMUM_DAYS_CREATED = 7
ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.8', remove_after: '2023-01-22'
ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.10', remove_after: '2023-02-22'
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour

View File

@ -5,8 +5,6 @@ module IncidentManagement
class BaseService
include Gitlab::Utils::UsageData
AUTOCREATE_TAGS = [TimelineEventTag::START_TIME_TAG_NAME, TimelineEventTag::END_TIME_TAG_NAME].freeze
def allowed?
user&.can?(:admin_incident_management_timeline_event, incident)
end
@ -47,7 +45,7 @@ module IncidentManagement
def auto_create_predefined_tags(new_tags)
new_tags = new_tags.map(&:downcase)
tags_to_create = AUTOCREATE_TAGS.select { |tag| tag.downcase.in?(new_tags) }
tags_to_create = TimelineEventTag::PREDEFINED_TAGS.select { |tag| tag.downcase.in?(new_tags) }
tags_to_create.each do |name|
project.incident_management_timeline_event_tags.create(name: name)

View File

@ -155,15 +155,14 @@ module IncidentManagement
def validate_tags(project, tag_names)
return [] unless tag_names&.any?
start_time_tag = AUTOCREATE_TAGS[0].downcase
end_time_tag = AUTOCREATE_TAGS[1].downcase
predefined_tags = TimelineEventTag::PREDEFINED_TAGS.map(&:downcase)
tag_names_downcased = tag_names.map(&:downcase)
tags = project.incident_management_timeline_event_tags.by_names(tag_names).pluck_names.map(&:downcase)
# remove tags from given tag_names and also remove predefined tags which can be auto created
tag_names_downcased - tags - [start_time_tag, end_time_tag]
tag_names_downcased - tags - predefined_tags
end
end
end

View File

@ -17,6 +17,7 @@
is_group: is_group.to_s,
group_id: @group&.id || '',
group_path: @group&.full_path,
maskable_raw_regex: ci_variable_maskable_raw_regex,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
aws_logo_svg_path: image_path('aws_logo.svg'),

View File

@ -15,8 +15,7 @@
.dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
= gl_redirect_listbox_tag [created_at, activity], @sort
= gl_redirect_listbox_tag [created_at, activity], @sort
= forks_sort_direction_button(sort_value)
- if current_user && can?(current_user, :fork_project, @project)

View File

@ -12,5 +12,5 @@
candidates: items,
metrics: metrics,
params: params,
pagination: @pagination.to_json
pagination: @pagination_info.to_json
} }

View File

@ -38,12 +38,11 @@ module Projects
def create_incident_management_timeline_event_tags(project)
tags = project.incident_management_timeline_event_tags.pluck_names
start_time_name = ::IncidentManagement::TimelineEventTag::START_TIME_TAG_NAME
end_time_name = ::IncidentManagement::TimelineEventTag::END_TIME_TAG_NAME
predefined_tags = ::IncidentManagement::TimelineEventTag::PREDEFINED_TAGS
project.incident_management_timeline_event_tags.new(name: start_time_name) unless tags.include?(start_time_name)
project.incident_management_timeline_event_tags.new(name: end_time_name) unless tags.include?(end_time_name)
predefined_tags.each do |tag|
project.incident_management_timeline_event_tags.new(name: tag) unless tags.include?(tag)
end
project.save!
rescue StandardError => e

View File

@ -0,0 +1,8 @@
---
name: ci_batch_request_for_local_and_project_includes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108826
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/387974
milestone: '15.9'
type: development
group: group::pipeline authoring
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: ci_remove_character_limitation_raw_masked_var
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109008
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/388414
milestone: '15.9'
type: development
group: group::pipeline authoring
default_enabled: false

View File

@ -4,5 +4,5 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56707
rollout_issue_url:
milestone: '13.11'
type: ops
group: group::authentication and authorization
group: group::anti-abuse
default_enabled: false

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class RemoveUserDetailsFieldsFromUser < Gitlab::Database::Migration[2.1]
enable_lock_retries!
def change
%i[linkedin twitter skype website_url].each do |column|
remove_column :users, column, :string, null: false, default: ''
end
%i[location organization].each do |column|
remove_column :users, column, :string, null: true
end
end
end

View File

@ -0,0 +1 @@
b699539dfc4453d93c64b6b3532531ec9000d61cfc81ae5267c2c52eb489632f

View File

@ -22893,9 +22893,6 @@ CREATE TABLE users (
name character varying,
admin boolean DEFAULT false NOT NULL,
projects_limit integer NOT NULL,
skype character varying DEFAULT ''::character varying NOT NULL,
linkedin character varying DEFAULT ''::character varying NOT NULL,
twitter character varying DEFAULT ''::character varying NOT NULL,
failed_attempts integer DEFAULT 0,
locked_at timestamp without time zone,
username character varying,
@ -22912,12 +22909,10 @@ CREATE TABLE users (
confirmation_sent_at timestamp without time zone,
unconfirmed_email character varying,
hide_no_ssh_key boolean DEFAULT false,
website_url character varying DEFAULT ''::character varying NOT NULL,
admin_email_unsubscribed_at timestamp without time zone,
notification_email character varying,
hide_no_password boolean DEFAULT false,
password_automatically_set boolean DEFAULT false,
location character varying,
encrypted_otp_secret character varying,
encrypted_otp_secret_iv character varying,
encrypted_otp_secret_salt character varying,
@ -22934,7 +22929,6 @@ CREATE TABLE users (
otp_grace_period_started_at timestamp without time zone,
external boolean DEFAULT false,
incoming_email_token character varying,
organization character varying,
auditor boolean DEFAULT false NOT NULL,
require_two_factor_authentication_from_group boolean DEFAULT false NOT NULL,
two_factor_grace_period integer DEFAULT 48 NOT NULL,

View File

@ -27,10 +27,10 @@ For more information, see the links shown on this page for each external provide
| Capability | SaaS | Self-managed |
|-------------------------------------------------|-----------------------------------------|------------------------------------|
| **User Provisioning** | SCIM<br>SAML <sup>1</sup> | LDAP <sup>1</sup><br>SAML <sup>1</sup><br>[OmniAuth Providers](../../integration/omniauth.md#supported-providers) <sup>1</sup> |
| **User Provisioning** | SCIM<br>SAML <sup>1</sup> | LDAP <sup>1</sup><br>SAML <sup>1</sup><br>[OmniAuth Providers](../../integration/omniauth.md#supported-providers) <sup>1</sup><br>SCIM |
| **User Detail Updating** (not group management) | Not Available | LDAP Sync |
| **Authentication** | SAML at top-level group (1 provider) | LDAP (multiple providers)<br>Generic OAuth 2.0<br>SAML (only 1 permitted per unique provider)<br>Kerberos<br>JWT<br>Smartcard<br>[OmniAuth Providers](../../integration/omniauth.md#supported-providers) (only 1 permitted per unique provider) |
| **Provider-to-GitLab Role Sync** | SAML Group Sync | LDAP Group Sync<br>SAML Group Sync ([GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/285150) and later) |
| **User Removal** | SCIM (remove user from top-level group) | LDAP (remove user from groups and block from the instance) |
| **User Removal** | SCIM (remove user from top-level group) | LDAP (remove user from groups and block from the instance)<br>SCIM |
1. Using Just-In-Time (JIT) provisioning, user accounts are created when the user first signs in.

View File

@ -147,6 +147,20 @@ http://secondary.example.com/
Last status report was: 1 minute ago
```
There are up to three statuses for each item. For example, for `Repositories`, you see the following lines:
```plaintext
Repositories: succeeded 12345 / total 12345 (100%)
Verified Repositories: succeeded 12345 / total 12345 (100%)
Repositories Checked: failed 5 / succeeded 0 / total 5 (0%)
```
The 3 status items are defined as follows:
- The `Repositories` output shows how many repositories are synced from the primary to the secondary.
- The `Verified Repositories` output shows how many repositories on this secondary have a matching repository checksum with the Primary.
- The `Repositories Checked` output shows how many repositories have passed a local Git repository check (`git fsck`) on the secondary.
To find more details about failed items, check
[the `gitlab-rails/geo.log` file](../../logs/log_parsing.md#find-most-common-geo-sync-errors)

View File

@ -703,7 +703,7 @@ Possible solutions:
## Profiling Gitaly
Gitaly exposes several of Golang's built-in performance profiling tools on the Prometheus listen port. For example, if Prometheus is listening
Gitaly exposes several of the Golang built-in performance profiling tools on the Prometheus listen port. For example, if Prometheus is listening
on port `9236` of the GitLab server:
- Get a list of running `goroutines` and their backtraces:
@ -724,7 +724,7 @@ on port `9236` of the GitLab server:
curl --output heap.bin "http://<gitaly_server>:9236/debug/pprof/heap"
```
- Record a 5 second execution trace. This will impact Gitaly's performance while running:
- Record a 5 second execution trace. This impacts the Gitaly performance while running:
```shell
curl --output trace.bin "http://<gitaly_server>:9236/debug/pprof/trace?seconds=5"

View File

@ -951,13 +951,25 @@ You can view descriptions of fields and arguments in:
#### Language and punctuation
Use `{x} of the {y}` where possible, where `{x}` is the item you're describing,
and `{y}` is the resource it applies to. For example:
To describe fields and arguments, use `{x} of the {y}` where possible,
where `{x}` is the item you're describing, and `{y}` is the resource it applies to. For example:
```plaintext
ID of the issue.
```
```plaintext
Author of the epics.
```
For arguments that sort or search, start with the appropriate verb.
To indicate the specified values, for conciseness, you can use `this` instead of
`the given` or `the specified`. For example:
```plaintext
Sort issues by this criteria.
```
Do not start descriptions with `The` or `A`, for consistency and conciseness.
End all descriptions with a period (`.`).

View File

@ -51,10 +51,25 @@ Known issues are gathered from within GitLab and from customer reported issues.
See the [GitLab AWS known issues list](https://gitlab.com/gitlab-com/alliances/aws/public-tracker/-/issues?label_name%5B%5D=AWS+Known+Issue) for a complete list.
## Official GitLab releases as AMIs
## Provision a single GitLab instance on AWS
If you want to provision a single GitLab instance on AWS, you have two options:
- The marketplace subscription
- The official GitLab AMIs
### Marketplace subscription
GitLab provides a 5 user subscription as an AWS Marketplace subscription to help teams of all sizes to get started with an Ultimate licensed instance in record time. The Marketplace subscription can be easily upgraded to any GitLab licensing via an AWS Marketplace Private Offer, with the convenience of continued AWS billing. No migration is necessary to obtain a larger, non-time based license from GitLab. Per-minute licensing is automatically removed when you accept the private offer.
For a tutorial on provisioning a GitLab Instance via a Marketplace Subscription, [use this tutorial](https://gitlab.awsworkshop.io/040_partner_setup.html). The tutorial links to the [GitLab Ultimate Marketplace Listing](https://aws.amazon.com/marketplace/pp/prodview-g6ktjmpuc33zk), but you can also use the [GitLab Premium Marketplace Listing](https://aws.amazon.com/marketplace/pp/prodview-amk6tacbois2k) to provision an instance.
### Official GitLab releases as AMIs
GitLab produces Amazon Machine Images (AMI) during the regular release process. The AMIs can be used for single instance GitLab installation or, by configuring `/etc/gitlab/gitlab.rb`, can be specialized for specific GitLab service roles (for example a Gitaly server). Older releases remain available and can be used to migrate an older GitLab server to AWS.
Initial licensing can either be the Free Enterprise License (EE) or the open source Community Edition (CE). The Enterprise Edition provides the easiest path forward to a licensed version if the need arises.
Currently the Amazon AMI uses the Amazon prepared Ubuntu AMI (x86 and ARM are available) as its starting point.
NOTE:

View File

@ -234,6 +234,6 @@ Once these steps are complete, GitLab has local copies of the Secure analyzers a
them instead of an Internet-hosted container image. This allows you to run Secure in AutoDevOps in
an offline environment.
Note that these steps are specific to GitLab Secure with AutoDevOps. Using other stages with
These steps are specific to GitLab Secure with AutoDevOps. Using other stages with
AutoDevOps may require other steps covered in the
[Auto DevOps documentation](../../../topics/autodevops/index.md).

View File

@ -146,7 +146,7 @@ the repository. For details on the Solution format, see the Microsoft reference
> Introduced in GitLab 14.2.
Vulnerabilities that have been detected and are false positives will be flagged as false positives in the security dashboard.
Vulnerabilities that have been detected and are false positives are flagged as false positives in the security dashboard.
False positive detection is available in a subset of the [supported languages](#supported-languages-and-frameworks) and [analyzers](analyzers.md):
@ -350,13 +350,13 @@ To override the automatic update behavior, set the `SAST_ANALYZER_IMAGE_TAG` CI/
in your CI/CD configuration file after you include the [`SAST.gitlab-ci.yml` template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/SAST.gitlab-ci.yml).
Only set this variable within a specific job.
If you set it [at the top level](../../../ci/variables/index.md#define-a-cicd-variable-in-the-gitlab-ciyml-file), the version you set will be used for other SAST analyzers.
If you set it [at the top level](../../../ci/variables/index.md#define-a-cicd-variable-in-the-gitlab-ciyml-file), the version you set is used for other SAST analyzers.
You can set the tag to:
- A major version, like `3`. Your pipelines will use any minor or patch updates that are released within this major version.
- A minor version, like `3.7`. Your pipelines will use any patch updates that are released within this minor version.
- A patch version, like `3.7.0`. Your pipelines won't receive any updates.
- A major version, like `3`. Your pipelines use any minor or patch updates that are released within this major version.
- A minor version, like `3.7`. Your pipelines use any patch updates that are released within this minor version.
- A patch version, like `3.7.0`. Your pipelines don't receive any updates.
This example uses a specific minor version of the `semgrep` analyzer and a specific patch version of the `brakeman` analyzer:
@ -552,7 +552,7 @@ Some analyzers make it possible to filter out vulnerabilities under a given thre
| CI/CD variable | Default value | Description |
|------------------------------|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `SAST_EXCLUDED_PATHS` | `spec, test, tests, tmp` | Exclude vulnerabilities from output based on the paths. This is a comma-separated list of patterns. Patterns can be globs (see [`doublestar.Match`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4@v4.0.2#Match) for supported patterns), or file or folder paths (for example, `doc,spec`). Parent directories also match patterns. You might need to exclude temporary directories used by your build tool as these can generate false positives. To exclude paths, copy and paste the default excluded paths, then **add** your own paths to be excluded. If you don't specify the default excluded paths, you will override the defaults and _only_ paths you specify will be excluded from the SAST scans. |
| `SAST_EXCLUDED_PATHS` | `spec, test, tests, tmp` | Exclude vulnerabilities from output based on the paths. This is a comma-separated list of patterns. Patterns can be globs (see [`doublestar.Match`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4@v4.0.2#Match) for supported patterns), or file or folder paths (for example, `doc,spec`). Parent directories also match patterns. You might need to exclude temporary directories used by your build tool as these can generate false positives. To exclude paths, copy and paste the default excluded paths, then **add** your own paths to be excluded. If you don't specify the default excluded paths, you override the defaults and _only_ paths you specify are excluded from the SAST scans. |
| `SEARCH_MAX_DEPTH` | 4 | SAST searches the repository to detect the programming languages used, and selects the matching analyzers. Set the value of `SEARCH_MAX_DEPTH` to specify how many directory levels the search phase should span. After the analyzers have been selected, the _entire_ repository is analyzed. |
| `SAST_BANDIT_EXCLUDED_PATHS` | | Comma-separated list of paths to exclude from scan. Uses Python's [`fnmatch` syntax](https://docs.python.org/2/library/fnmatch.html); For example: `'*/tests/*, */venv/*'`. [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/352554) in GitLab 15.4. |
| `SAST_BRAKEMAN_LEVEL` | 1 | Ignore Brakeman vulnerabilities under given confidence level. Integer, 1=Low 3=High. |

View File

@ -199,7 +199,7 @@ To sort vulnerabilities by the date each vulnerability was detected, select the
> - [Added](https://gitlab.com/gitlab-org/gitlab/-/issues/213013) to the group-level Vulnerability Report in GitLab 13.1.
You can export details of the vulnerabilities listed in the Vulnerability Report. The export format
is CSV (comma separated values). Note that all vulnerabilities are included because filters do not
is CSV (comma separated values). All vulnerabilities are included because filters do not
apply to the export.
Fields included are:
@ -263,7 +263,7 @@ To add a new vulnerability finding from your project level Vulnerability Report
1. Select **Submit vulnerability**.
1. Complete the fields and submit the form.
You will be brought to the newly created vulnerability's detail page. Manually created records appear in the
You are brought to the newly created vulnerability's detail page. Manually created records appear in the
Group, Project, and Security Center Vulnerability Reports. To filter them, use the Generic Tool filter.
## Operational vulnerabilities

View File

@ -38,7 +38,7 @@ Before GitLab displays results, the vulnerability findings in all pipeline repor
GitLab displays one row of information for each [scan type](../terminology/index.md#scan-type-report-type) artifact present in
the pipeline.
Note that each scan type's total number of vulnerabilities includes dismissed findings. If the number of findings
Each scan type's total number of vulnerabilities includes dismissed findings. If the number of findings
in the report doesn't match the number in **Scan details**, ensure that **Hide dismissed** is disabled.
### Download security scan outputs
@ -77,7 +77,7 @@ incorporated once the pipeline finishes.
| Confirmed | No | Confirmed |
| Needs triage (Detected) | No | Needs triage (Detected) |
| Resolved | No | Needs triage (Detected) |
| N/A (i.e.: new vulnerability) | No | Needs triage (Detected) |
| N/A (That is: new vulnerability) | No | Needs triage (Detected) |
## Retention period for vulnerabilities

View File

@ -76,7 +76,7 @@ observability:
grpc_level: warn
```
When `grpc_level` is set to `info` or below, there will be a lot of gRPC logs.
When `grpc_level` is set to `info` or below, there are a lot of gRPC logs.
Commit the configuration changes and inspect the agent service logs:

View File

@ -301,7 +301,7 @@ The list of GitLab.com specific settings (and their defaults) is as follows:
| `autovacuum_vacuum_scale_factor` | 0.01 | 0.02 |
| `checkpoint_completion_target` | 0.7 | 0.9 |
| `checkpoint_segments` | 32 | 10 |
| `effective_cache_size` | 338688MB | Based on how much memory is available |
| `effective_cache_size` | 338688 MB | Based on how much memory is available |
| `hot_standby` | on | off |
| `hot_standby_feedback` | on | off |
| `log_autovacuum_min_duration` | 0 | -1 |
@ -309,19 +309,19 @@ The list of GitLab.com specific settings (and their defaults) is as follows:
| `log_line_prefix` | `%t [%p]: [%l-1]` | empty |
| `log_min_duration_statement` | 1000 | -1 |
| `log_temp_files` | 0 | -1 |
| `maintenance_work_mem` | 2048MB | 16 MB |
| `maintenance_work_mem` | 2048 MB | 16 MB |
| `max_replication_slots` | 5 | 0 |
| `max_wal_senders` | 32 | 0 |
| `max_wal_size` | 5GB | 1GB |
| `shared_buffers` | 112896MB | Based on how much memory is available |
| `max_wal_size` | 5 GB | 1 GB |
| `shared_buffers` | 112896 MB | Based on how much memory is available |
| `shared_preload_libraries` | `pg_stat_statements` | empty |
| `shmall` | 30146560 | Based on the server's capabilities |
| `shmmax` | 123480309760 | Based on the server's capabilities |
| `wal_buffers` | 16MB | -1 |
| `wal_buffers` | 16 MB | -1 |
| `wal_keep_segments` | 512 | 10 |
| `wal_level` | replica | minimal |
| `statement_timeout` | 15s | 60s |
| `idle_in_transaction_session_timeout` | 60s | 60s |
| `statement_timeout` | 15 s | 60 s |
| `idle_in_transaction_session_timeout` | 60 s | 60 s |
Some of these settings are in the process being adjusted. For example, the value
for `shared_buffers` is quite high, and we are

View File

@ -205,7 +205,7 @@ By default, projects in a group can be forked.
Optionally, on [GitLab Premium](https://about.gitlab.com/pricing/) or higher tiers,
you can prevent the projects in a group from being forked outside of the current top-level group.
This setting will be removed from the SAML setting page, and migrated to the
This setting is removed from the SAML setting page, and migrated to the
group settings page. In the interim period, both of these settings are taken into consideration.
If even one is set to `true`, then the group does not allow outside forks.
@ -296,7 +296,7 @@ Now you can edit the user's permissions from the **Members** page.
### Verify if access is blocked by IP restriction
If a user sees a 404 when they would normally expect access, and the problem is limited to a specific group, search the `auth.log` rails log for one or more of the following:
If a user sees a 404 when they would usually expect access, and the problem is limited to a specific group, search the `auth.log` rails log for one or more of the following:
- `json.message`: `'Attempting to access IP restricted group'`
- `json.allowed`: `false`

View File

@ -330,7 +330,7 @@ This table shows granted privileges for jobs triggered by specific types of user
| Push source and LFS | | | | |
1. Only if the triggering user is not an external one.
1. Only if the triggering user is a member of the project. See also [Usage of private Docker images with `if-not-present` pull policy](http://docs.gitlabl.com/runner/security/index.html#usage-of-private-docker-images-with-if-not-present-pull-policy).
1. Only if the triggering user is a member of the project. See also [Usage of private Docker images with `if-not-present` pull policy](http://docs.gitlab.com/runner/security/index.html#usage-of-private-docker-images-with-if-not-present-pull-policy).
## Group members permissions

View File

@ -297,7 +297,7 @@ If you regenerate 2FA recovery codes, save them. You can't use any previously cr
## Sign in with two-factor authentication enabled
Signing in with 2FA enabled is only slightly different than the normal sign-in process. Enter your username and password
Signing in with 2FA enabled is only slightly different than the typical sign-in process. Enter your username and password
and you're presented with a second prompt, depending on which type of 2FA you've enabled.
### Sign in using a one-time password

View File

@ -54,7 +54,7 @@ Wikipedia article on [comparing the different version control software](https://
CVS is old with no new release since 2008. Git provides more tools to work
with (`git bisect` for one) which makes for a more productive workflow.
Migrating to Git/GitLab will benefit you:
Migrating to Git/GitLab benefits you:
- **Shorter learning curve**, Git has a big community and a vast number of
tutorials to get you started (see our [Git topic](../../../topics/git/index.md)).

View File

@ -54,8 +54,8 @@ You can import projects from:
- [Uploading a manifest file (AOSP)](manifest.md)
- [Jira (issues only)](jira.md)
You can also import any Git repository through HTTP from the **New Project** page. Note that if the
repository is too large, the import can timeout.
You can also import any Git repository through HTTP from the **New Project** page. If the repository
is too large, the import can timeout.
You can then [connect your external repository to get CI/CD benefits](../../../ci/ci_cd_for_external_repos/index.md).
@ -89,7 +89,7 @@ The backups produced don't depend on the operating system running GitLab. You ca
the restore method to switch between different operating system distributions or versions, as long
as the same GitLab version [is available for installation](../../../administration/package_information/supported_os.md).
Also note that administrators can use the [Users API](../../../api/users.md) to migrate users.
Administrators can use the [Users API](../../../api/users.md) to migrate users.
## View project import history

View File

@ -36,8 +36,6 @@ module Gitlab
)
end
private
def validate_context!
context.logger.instrument(:config_file_artifact_validate_context) do
if !creating_child_pipeline?
@ -54,6 +52,8 @@ module Gitlab
errors.push("File `#{masked_location}` is empty!") unless content.present?
end
private
def artifact_job
strong_memoize(:artifact_job) do
next unless creating_child_pipeline?

View File

@ -46,6 +46,7 @@ module Gitlab
expanded_content_hash
end
# Will be removed with the FF ci_batch_request_for_local_and_project_includes
def validate!
validate_location!
validate_context! if valid?
@ -68,22 +69,16 @@ module Gitlab
[params, context.project&.full_path, context.sha].hash
end
protected
def expanded_content_hash
return unless content_hash
strong_memoize(:expanded_content_yaml) do
expand_includes(content_hash)
def load_and_validate_expanded_hash!
context.logger.instrument(:config_file_fetch_content_hash) do
content_hash # calling the method loads then memoizes the result
end
end
def content_hash
strong_memoize(:content_yaml) do
::Gitlab::Ci::Config::Yaml.load!(content)
context.logger.instrument(:config_file_expand_content_includes) do
expanded_content_hash # calling the method expands then memoizes the result
end
rescue Gitlab::Config::Loader::FormatError
nil
validate_hash!
end
def validate_location!
@ -98,6 +93,31 @@ module Gitlab
raise NotImplementedError, 'subclass must implement validate_context'
end
def validate_content!
if content.blank?
errors.push("Included file `#{masked_location}` is empty or does not exist!")
end
end
protected
def expanded_content_hash
return unless content_hash
strong_memoize(:expanded_content_hash) do
expand_includes(content_hash)
end
end
def content_hash
strong_memoize(:content_hash) do
::Gitlab::Ci::Config::Yaml.load!(content)
end
rescue Gitlab::Config::Loader::FormatError
nil
end
# Will be removed with the FF ci_batch_request_for_local_and_project_includes
def fetch_and_validate_content!
context.logger.instrument(:config_file_fetch_content) do
content # calling the method fetches then memoizes the result
@ -110,24 +130,6 @@ module Gitlab
end
end
def load_and_validate_expanded_hash!
context.logger.instrument(:config_file_fetch_content_hash) do
content_hash # calling the method loads then memoizes the result
end
context.logger.instrument(:config_file_expand_content_includes) do
expanded_content_hash # calling the method expands then memoizes the result
end
validate_hash!
end
def validate_content!
if content.blank?
errors.push("Included file `#{masked_location}` is empty or does not exist!")
end
end
def validate_hash!
if to_hash.blank?
errors.push("Included file `#{masked_location}` does not have valid YAML syntax!")

View File

@ -10,7 +10,12 @@ module Gitlab
include Gitlab::Utils::StrongMemoize
def initialize(params, context)
@location = params[:local]
@location = if ::Feature.enabled?(:ci_batch_request_for_local_and_project_includes, context.project)
# `Repository#blobs_at` does not support files with the `/` prefix.
Gitlab::Utils.remove_leading_slashes(params[:local])
else
params[:local]
end
super
end
@ -29,8 +34,6 @@ module Gitlab
)
end
private
def validate_context!
return if context.project&.repository
@ -45,7 +48,27 @@ module Gitlab
end
end
private
def fetch_local_content
if ::Feature.disabled?(:ci_batch_request_for_local_and_project_includes, context.project)
return legacy_fetch_local_content
end
BatchLoader.for([context.sha, location])
.batch(key: context.project) do |locations, loader, args|
context.logger.instrument(:config_file_fetch_local_content) do
args[:key].repository.blobs_at(locations).each do |blob|
loader.call([blob.commit_id, blob.path], blob.data)
end
end
rescue GRPC::InvalidArgument
# no-op
end
end
# Will be removed with the FF ci_batch_request_for_local_and_project_includes
def legacy_fetch_local_content
context.logger.instrument(:config_file_fetch_local_content) do
context.project.repository.blob_data_at(context.sha, location)
end

View File

@ -37,8 +37,6 @@ module Gitlab
)
end
private
def validate_context!
if !can_access_local_content?
errors.push("Project `#{masked_project_name}` not found or access denied! Make sure any includes in the pipeline configuration are correctly defined.")
@ -55,6 +53,8 @@ module Gitlab
end
end
private
def project
strong_memoize(:project) do
::Project.find_by_full_path(project_name)

View File

@ -28,8 +28,6 @@ module Gitlab
)
end
private
def validate_context!
# no-op
end
@ -42,6 +40,8 @@ module Gitlab
end
end
private
def fetch_remote_content
begin
response = context.logger.instrument(:config_file_fetch_remote_content) do

View File

@ -31,8 +31,6 @@ module Gitlab
)
end
private
def validate_context!
# no-op
end
@ -45,6 +43,8 @@ module Gitlab
end
end
private
def template_name
return unless template_name_valid?

View File

@ -10,6 +10,34 @@ module Gitlab
private
def process_without_instrumentation(files)
if ::Feature.disabled?(:ci_batch_request_for_local_and_project_includes, context.project)
return legacy_process_without_instrumentation(files)
end
files.each do |file|
verify_execution_time!
file.validate_location!
file.validate_context! if file.valid?
file.content if file.valid?
end
# We do not combine the loops because we need to load the content of all files before continuing
# to call `BatchLoader` for all locations.
files.each do |file| # rubocop:disable Style/CombinableLoops
# Checking the max includes will be changed with https://gitlab.com/gitlab-org/gitlab/-/issues/367150
verify_max_includes!
verify_execution_time!
file.validate_content! if file.valid?
file.load_and_validate_expanded_hash! if file.valid?
context.expandset.add(file)
end
end
# Will be removed with the FF ci_batch_request_for_local_and_project_includes
def legacy_process_without_instrumentation(files)
files.select do |file|
verify_max_includes!
verify_execution_time!

View File

@ -82,7 +82,11 @@ module Gitlab
# Append path to host, making sure there's one single / in between
def append_path(host, path)
"#{host.to_s.sub(%r{\/+$}, '')}/#{path.to_s.sub(%r{^\/+}, '')}"
"#{host.to_s.sub(%r{\/+$}, '')}/#{remove_leading_slashes(path)}"
end
def remove_leading_slashes(str)
str.to_s.sub(%r{^/+}, '')
end
# A slugified version of the string, suitable for inclusion in URLs and

View File

@ -1374,9 +1374,6 @@ msgstr ""
msgid ", or "
msgstr ""
msgid "-"
msgstr ""
msgid "- %{policy_name} (notifying after %{elapsed_time} minutes unless %{status})"
msgstr ""
@ -16705,9 +16702,6 @@ msgstr ""
msgid "Experiment"
msgstr ""
msgid "Experiment candidates"
msgstr ""
msgid "Experiments"
msgstr ""
@ -27198,6 +27192,33 @@ msgstr ""
msgid "MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile"
msgstr ""
msgid "MlExperimentTracking|-"
msgstr ""
msgid "MlExperimentTracking|Artifacts"
msgstr ""
msgid "MlExperimentTracking|Created at"
msgstr ""
msgid "MlExperimentTracking|Details"
msgstr ""
msgid "MlExperimentTracking|Experiment candidates"
msgstr ""
msgid "MlExperimentTracking|Filter candidates"
msgstr ""
msgid "MlExperimentTracking|Name"
msgstr ""
msgid "MlExperimentTracking|No candidates to display"
msgstr ""
msgid "MlExperimentTracking|User"
msgstr ""
msgid "MlExperimentsEmptyState|No Experiments to Show"
msgstr ""
@ -43156,9 +43177,6 @@ msgstr ""
msgid "This epic would exceed maximum number of related epics."
msgstr ""
msgid "This experiment has no logged candidates"
msgstr ""
msgid "This feature requires local storage to be enabled"
msgstr ""

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Ml::CandidateFinder, feature_category: :mlops do
let_it_be(:experiment) { create(:ml_experiments, user: nil) }
let_it_be(:candidates) do
%w[c a da b].zip([3, 2, 4, 1]).map do |name, auc|
make_candidate_and_metric(name, auc, experiment)
end
end
let_it_be(:another_candidate) { create(:ml_candidates) }
let_it_be(:first_candidate) { candidates.first }
let(:finder) { described_class.new(experiment, params) }
let(:page) { 1 }
let(:default_params) { { page: page } }
let(:params) { default_params }
subject { finder.execute }
describe '.execute' do
describe 'by name' do
context 'when params has no name' do
it 'fetches all candidates in the experiment' do
expect(subject).to match_array(candidates)
end
it 'does not fetch candidate not in experiment' do
expect(subject).not_to include(another_candidate)
end
end
context 'when name is included in params' do
let(:params) { { name: 'a' } }
it 'fetches the correct candidates' do
expect(subject).to match_array(candidates.values_at(2, 1))
end
end
end
describe 'sorting' do
using RSpec::Parameterized::TableSyntax
where(:test_case, :order_by, :order_by_type, :direction, :expected_order) do
'default params' | nil | nil | nil | [3, 2, 1, 0]
'ascending order' | nil | nil | 'ASC' | [0, 1, 2, 3]
'column is passed' | 'name' | 'column' | 'ASC' | [1, 3, 0, 2]
'column is a metric' | 'auc' | 'metric' | nil | [2, 0, 1, 3]
'invalid sort' | nil | nil | 'INVALID' | [3, 2, 1, 0]
'invalid order by' | 'INVALID' | 'column' | 'desc' | [3, 2, 1, 0]
'invalid order by metric' | nil | 'metric' | 'desc' | []
end
with_them do
let(:params) { { order_by: order_by, order_by_type: order_by_type, sort: direction } }
it { expect(subject).to eq(candidates.values_at(*expected_order)) }
end
end
context 'when name and sort by metric is passed' do
let(:params) { { order_by: 'auc', order_by_type: 'metric', sort: 'DESC', name: 'a' } }
it { expect(subject).to eq(candidates.values_at(2, 1)) }
end
end
private
def make_candidate_and_metric(name, auc_value, experiment)
create(:ml_candidates, name: name, experiment: experiment, user: nil).tap do |c|
create(:ml_candidate_metrics, name: 'auc', candidate_id: c.id, value: 10)
create(:ml_candidate_metrics, name: 'auc', candidate_id: c.id, value: auc_value)
end
end
end

View File

@ -21,6 +21,8 @@ describe('Ci variable modal', () => {
let trackingSpy;
const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$';
const maskableRawRegex = '^\\S{8,}$';
const mockVariables = mockVariablesWithScopes(instanceString);
const defaultProvide = {
@ -30,8 +32,12 @@ describe('Ci variable modal', () => {
awsTipLearnLink: '/learn-link',
containsVariableReferenceLink: '/reference',
environmentScopeLink: '/help/environments',
glFeatures: {
ciRemoveCharacterLimitationRawMaskedVar: true,
},
isProtectedByDefault: false,
maskedEnvironmentVariablesLink: '/variables-link',
maskableRawRegex,
maskableRegex,
protectedEnvironmentVariablesLink: '/protected-link',
};
@ -424,6 +430,54 @@ describe('Ci variable modal', () => {
describe('Validations', () => {
const maskError = 'This variable can not be masked.';
describe('when the variable is raw', () => {
const [variable] = mockVariables;
const validRawMaskedVariable = {
...variable,
value: 'd$%^asdsadas',
masked: false,
raw: true,
};
describe('and FF is enabled', () => {
beforeEach(() => {
createComponent({
mountFn: mountExtended,
props: { selectedVariable: validRawMaskedVariable },
});
});
it('should not show an error with symbols', async () => {
await findMaskedVariableCheckbox().trigger('click');
expect(findModal().text()).not.toContain(maskError);
});
it('should not show an error when length is less than 8', async () => {
await findValueField().vm.$emit('input', 'a');
await findMaskedVariableCheckbox().trigger('click');
expect(findModal().text()).toContain(maskError);
});
});
describe('and FF is disabled', () => {
beforeEach(() => {
createComponent({
mountFn: mountExtended,
props: { selectedVariable: validRawMaskedVariable },
provide: { glFeatures: { ciRemoveCharacterLimitationRawMaskedVar: false } },
});
});
it('should show an error with symbols', async () => {
await findMaskedVariableCheckbox().trigger('click');
expect(findModal().text()).toContain(maskError);
});
});
});
describe('when the mask state is invalid', () => {
beforeEach(async () => {
const [variable] = mockVariables;

View File

@ -1,761 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MlExperiment with candidates renders correctly 1`] = `
<div>
<div
class="gl-alert gl-alert-warning"
>
<svg
aria-hidden="true"
class="gl-icon s16 gl-alert-icon"
data-testid="warning-icon"
role="img"
>
<use
href="#warning"
/>
</svg>
<div
aria-live="assertive"
class="gl-alert-content"
role="alert"
>
<h2
class="gl-alert-title"
>
Machine Learning Experiment Tracking is in Incubating Phase
</h2>
<div
class="gl-alert-body"
>
GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited
<a
class="gl-link"
href="https://about.gitlab.com/handbook/engineering/incubation/"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</a>
</div>
<div
class="gl-alert-actions"
>
<a
class="btn gl-alert-action btn-confirm btn-md gl-button"
href="https://gitlab.com/gitlab-org/gitlab/-/issues/381660"
>
<!---->
<!---->
<span
class="gl-button-text"
>
Feedback
</span>
</a>
</div>
</div>
<button
aria-label="Dismiss"
class="btn gl-dismiss-btn btn-default btn-sm gl-button btn-default-tertiary btn-icon"
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="close-icon"
role="img"
>
<use
href="#close"
/>
</svg>
<!---->
</button>
</div>
<h3>
Experiment candidates
</h3>
<table
aria-busy="false"
aria-colcount="9"
class="table b-table gl-table gl-mt-0! ml-candidate-table table-sm"
role="table"
>
<!---->
<!---->
<thead
class=""
role="rowgroup"
>
<!---->
<tr
class=""
role="row"
>
<th
aria-colindex="1"
class=""
role="columnheader"
scope="col"
>
<div>
Name
</div>
</th>
<th
aria-colindex="2"
class=""
role="columnheader"
scope="col"
>
<div>
Created at
</div>
</th>
<th
aria-colindex="3"
class=""
role="columnheader"
scope="col"
>
<div>
User
</div>
</th>
<th
aria-colindex="4"
class=""
role="columnheader"
scope="col"
>
<div>
L1 Ratio
</div>
</th>
<th
aria-colindex="5"
class=""
role="columnheader"
scope="col"
>
<div>
Rmse
</div>
</th>
<th
aria-colindex="6"
class=""
role="columnheader"
scope="col"
>
<div>
Auc
</div>
</th>
<th
aria-colindex="7"
class=""
role="columnheader"
scope="col"
>
<div>
Mae
</div>
</th>
<th
aria-colindex="8"
aria-label="Details"
class=""
role="columnheader"
scope="col"
>
<div />
</th>
<th
aria-colindex="9"
aria-label="Artifact"
class=""
role="columnheader"
scope="col"
>
<div />
</th>
</tr>
</thead>
<tbody
role="rowgroup"
>
<!---->
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class=""
role="cell"
>
<div
title="aCandidate"
>
aCandidate
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
<time
class=""
datetime="2023-01-05T14:07:02.975Z"
title="2023-01-05T14:07:02.975Z"
>
in 2 years
</time>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
<a
class="gl-link"
href="/root"
title="root"
>
@root
</a>
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<div
title="0.4"
>
0.4
</div>
</td>
<td
aria-colindex="5"
class=""
role="cell"
>
<div
title="1"
>
1
</div>
</td>
<td
aria-colindex="6"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="7"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="8"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_candidate1"
title="Details"
>
Details
</a>
</td>
<td
aria-colindex="9"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_artifact"
rel="noopener"
target="_blank"
title="Artifacts"
>
Artifacts
</a>
</td>
</tr>
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
<time
class=""
datetime="2023-01-05T14:07:02.975Z"
title="2023-01-05T14:07:02.975Z"
>
in 2 years
</time>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
<div>
-
</div>
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<div
title="0.5"
>
0.5
</div>
</td>
<td
aria-colindex="5"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="6"
class=""
role="cell"
>
<div
title="0.3"
>
0.3
</div>
</td>
<td
aria-colindex="7"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="8"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_candidate2"
title="Details"
>
Details
</a>
</td>
<td
aria-colindex="9"
class=""
role="cell"
>
<div
title="Artifacts"
>
-
</div>
</td>
</tr>
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
<time
class=""
datetime="2023-01-05T14:07:02.975Z"
title="2023-01-05T14:07:02.975Z"
>
in 2 years
</time>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
<div>
-
</div>
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<div
title="0.5"
>
0.5
</div>
</td>
<td
aria-colindex="5"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="6"
class=""
role="cell"
>
<div
title="0.3"
>
0.3
</div>
</td>
<td
aria-colindex="7"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="8"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_candidate3"
title="Details"
>
Details
</a>
</td>
<td
aria-colindex="9"
class=""
role="cell"
>
<div
title="Artifacts"
>
-
</div>
</td>
</tr>
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
<time
class=""
datetime="2023-01-05T14:07:02.975Z"
title="2023-01-05T14:07:02.975Z"
>
in 2 years
</time>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
<div>
-
</div>
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<div
title="0.5"
>
0.5
</div>
</td>
<td
aria-colindex="5"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="6"
class=""
role="cell"
>
<div
title="0.3"
>
0.3
</div>
</td>
<td
aria-colindex="7"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="8"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_candidate4"
title="Details"
>
Details
</a>
</td>
<td
aria-colindex="9"
class=""
role="cell"
>
<div
title="Artifacts"
>
-
</div>
</td>
</tr>
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
<time
class=""
datetime="2023-01-05T14:07:02.975Z"
title="2023-01-05T14:07:02.975Z"
>
in 2 years
</time>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
<div>
-
</div>
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<div
title="0.5"
>
0.5
</div>
</td>
<td
aria-colindex="5"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="6"
class=""
role="cell"
>
<div
title="0.3"
>
0.3
</div>
</td>
<td
aria-colindex="7"
class=""
role="cell"
>
<div
title=""
>
</div>
</td>
<td
aria-colindex="8"
class=""
role="cell"
>
<a
class="gl-link"
href="link_to_candidate5"
title="Details"
>
Details
</a>
</td>
<td
aria-colindex="9"
class=""
role="cell"
>
<div
title="Artifacts"
>
-
</div>
</td>
</tr>
<!---->
<!---->
</tbody>
<!---->
</table>
<!---->
</div>
`;

View File

@ -1,6 +1,10 @@
import { GlAlert, GlPagination } from '@gitlab/ui';
import { GlAlert, GlPagination, GlTable, GlLink } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import setWindowLocation from 'helpers/set_window_location_helper';
import * as urlHelpers from '~/lib/utils/url_utility';
describe('MlExperiment', () => {
let wrapper;
@ -11,130 +15,340 @@ describe('MlExperiment', () => {
paramNames = [],
pagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 0 },
) => {
return mountExtended(MlExperiment, {
wrapper = mountExtended(MlExperiment, {
provide: { candidates, metricNames, paramNames, pagination },
});
};
const findAlert = () => wrapper.findComponent(GlAlert);
const defaultPagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 5 };
const findEmptyState = () => wrapper.findByText('This experiment has no logged candidates');
const candidates = [
{
rmse: 1,
l1_ratio: 0.4,
details: 'link_to_candidate1',
artifact: 'link_to_artifact',
name: 'aCandidate',
created_at: '2023-01-05T14:07:02.975Z',
user: { username: 'root', path: '/root' },
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate2',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate3',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate4',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate5',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
];
const createWrapperWithCandidates = (pagination = defaultPagination) => {
createWrapper(candidates, ['rmse', 'auc', 'mae'], ['l1_ratio'], pagination);
};
const findAlert = () => wrapper.findComponent(GlAlert);
const findPagination = () => wrapper.findComponent(GlPagination);
const findEmptyState = () => wrapper.findByText('No candidates to display');
const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findTable = () => wrapper.findComponent(GlTable);
const findTableHeaders = () => findTable().findAll('th');
const findTableRows = () => findTable().findAll('tbody > tr');
const findNthTableRow = (idx) => findTableRows().at(idx);
const findColumnInRow = (row, col) => findNthTableRow(row).findAll('td').at(col);
const hrefInRowAndColumn = (row, col) =>
findColumnInRow(row, col).findComponent(GlLink).attributes().href;
it('shows incubation warning', () => {
wrapper = createWrapper();
createWrapper();
expect(findAlert().exists()).toBe(true);
});
describe('no candidates', () => {
it('shows empty state', () => {
wrapper = createWrapper();
describe('default inputs', () => {
beforeEach(async () => {
createWrapper();
await nextTick();
});
it('shows empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('does not show pagination', () => {
wrapper = createWrapper();
expect(findPagination().exists()).toBe(false);
});
expect(wrapper.findComponent(GlPagination).exists()).toBe(false);
it('there are no columns', () => {
expect(findTable().findAll('th')).toHaveLength(0);
});
it('initializes sorting correctly', () => {
expect(findRegistrySearch().props('sorting')).toMatchObject({
orderBy: 'created_at',
sort: 'desc',
});
});
it('initializes filters correctly', () => {
expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: '' } }]);
});
});
describe('generateLink', () => {
it('generates the correct url', () => {
setWindowLocation(
'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=1',
);
createWrapperWithCandidates();
expect(findPagination().props('linkGen')(2)).toBe(
'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=2',
);
});
it('generates the correct url when no name', () => {
setWindowLocation('https://blah.com/?orderBy=auc&orderByType=metric&sort=asc');
createWrapperWithCandidates();
expect(findPagination().props('linkGen')(2)).toBe(
'https://blah.com/?orderBy=auc&orderByType=metric&sort=asc&page=2',
);
});
});
describe('Search', () => {
it('shows search box', () => {
createWrapper();
expect(findRegistrySearch().exists()).toBe(true);
});
it('metrics are added as options for sorting', () => {
createWrapper([], ['bar']);
const labels = findRegistrySearch()
.props('sortableFields')
.map((e) => e.orderBy);
expect(labels).toContain('metric.bar');
});
it('sets the component filters based on the querystring', () => {
setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
createWrapper();
expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: 'A' } }]);
});
it('sets the component sort based on the querystring', () => {
setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
createWrapper();
expect(findRegistrySearch().props('sorting')).toMatchObject({ orderBy: 'B', sort: 'c' });
});
it('sets the component sort based on the querystring, when order by is a metric', () => {
setWindowLocation('https://blah?name=A&orderBy=B&sort=C&orderByType=metric');
createWrapper();
expect(findRegistrySearch().props('sorting')).toMatchObject({
orderBy: 'metric.B',
sort: 'c',
});
});
describe('Search submit', () => {
beforeEach(() => {
setWindowLocation('https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc');
jest.spyOn(urlHelpers, 'visitUrl').mockImplementation(() => {});
createWrapper();
});
it('On submit, reloads to correct page', () => {
findRegistrySearch().vm.$emit('filter:submit');
expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=1',
);
});
it('On sorting changed, reloads to correct page', () => {
findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'created_at' });
expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
'https://blah.com/?name=query&orderBy=created_at&orderByType=column&sort=asc&page=1',
);
});
it('On sorting changed and is metric, reloads to correct page', () => {
findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'metric.auc' });
expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
'https://blah.com/?name=query&orderBy=auc&orderByType=metric&sort=asc&page=1',
);
});
it('On direction changed, reloads to correct page', () => {
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'desc' });
expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=desc&page=1',
);
});
});
});
describe('with candidates', () => {
const defaultPagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 5 };
const createWrapperWithCandidates = (pagination = defaultPagination) => {
return createWrapper(
[
{
rmse: 1,
l1_ratio: 0.4,
details: 'link_to_candidate1',
artifact: 'link_to_artifact',
name: 'aCandidate',
created_at: '2023-01-05T14:07:02.975Z',
user: { username: 'root', path: '/root' },
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate2',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate3',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate4',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
{
auc: 0.3,
l1_ratio: 0.5,
details: 'link_to_candidate5',
created_at: '2023-01-05T14:07:02.975Z',
name: null,
user: null,
},
],
['rmse', 'auc', 'mae'],
['l1_ratio'],
pagination,
);
};
it('renders correctly', () => {
wrapper = createWrapperWithCandidates();
expect(wrapper.element).toMatchSnapshot();
});
describe('Pagination behaviour', () => {
it('should show', () => {
wrapper = createWrapperWithCandidates();
beforeEach(() => {
createWrapperWithCandidates();
});
expect(wrapper.findComponent(GlPagination).exists()).toBe(true);
it('should show', () => {
expect(findPagination().exists()).toBe(true);
});
it('should get the page number from the URL', () => {
wrapper = createWrapperWithCandidates({ ...defaultPagination, page: 2 });
createWrapperWithCandidates({ ...defaultPagination, page: 2 });
expect(wrapper.findComponent(GlPagination).props().value).toBe(2);
expect(findPagination().props().value).toBe(2);
});
it('should not have a prevPage if the page is 1', () => {
wrapper = createWrapperWithCandidates();
expect(wrapper.findComponent(GlPagination).props().prevPage).toBe(null);
expect(findPagination().props().prevPage).toBe(null);
});
it('should set the prevPage to 1 if the page is 2', () => {
wrapper = createWrapperWithCandidates({ ...defaultPagination, page: 2 });
createWrapperWithCandidates({ ...defaultPagination, page: 2 });
expect(wrapper.findComponent(GlPagination).props().prevPage).toBe(1);
expect(findPagination().props().prevPage).toBe(1);
});
it('should not have a nextPage if isLastPage is true', async () => {
wrapper = createWrapperWithCandidates({ ...defaultPagination, isLastPage: true });
createWrapperWithCandidates({ ...defaultPagination, isLastPage: true });
expect(wrapper.findComponent(GlPagination).props().nextPage).toBe(null);
expect(findPagination().props().nextPage).toBe(null);
});
it('should set the nextPage to 2 if the page is 1', () => {
wrapper = createWrapperWithCandidates();
expect(findPagination().props().nextPage).toBe(2);
});
});
});
expect(wrapper.findComponent(GlPagination).props().nextPage).toBe(2);
describe('Candidate table', () => {
const firstCandidateIndex = 0;
const secondCandidateIndex = 1;
const firstCandidate = candidates[firstCandidateIndex];
beforeEach(() => {
createWrapperWithCandidates();
});
it('renders all rows', () => {
expect(findTableRows()).toHaveLength(candidates.length);
});
it('sets the correct columns in the table', () => {
const expectedColumnNames = [
'Name',
'Created at',
'User',
'L1 Ratio',
'Rmse',
'Auc',
'Mae',
'',
'',
];
expect(findTableHeaders().wrappers.map((h) => h.text())).toEqual(expectedColumnNames);
});
describe('Artifact column', () => {
const artifactColumnIndex = -1;
it('shows the a link to the artifact', () => {
expect(hrefInRowAndColumn(firstCandidateIndex, artifactColumnIndex)).toBe(
firstCandidate.artifact,
);
});
it('shows empty state when no artifact', () => {
expect(findColumnInRow(secondCandidateIndex, artifactColumnIndex).text()).toBe('-');
});
});
describe('User column', () => {
const userColumn = 2;
it('creates a link to the user', () => {
const column = findColumnInRow(firstCandidateIndex, userColumn).findComponent(GlLink);
expect(column.attributes().href).toBe(firstCandidate.user.path);
expect(column.text()).toBe(`@${firstCandidate.user.username}`);
});
it('when there is no user shows empty state', () => {
createWrapperWithCandidates();
expect(findColumnInRow(secondCandidateIndex, userColumn).text()).toBe('-');
});
});
describe('Candidate name column', () => {
const nameColumnIndex = 0;
it('Sets the name', () => {
expect(findColumnInRow(firstCandidateIndex, nameColumnIndex).text()).toBe(
firstCandidate.name,
);
});
it('when there is no user shows nothing', () => {
expect(findColumnInRow(secondCandidateIndex, nameColumnIndex).text()).toBe('');
});
});
describe('Detail column', () => {
const detailColumn = -2;
it('is a link to details', () => {
expect(hrefInRowAndColumn(firstCandidateIndex, detailColumn)).toBe(firstCandidate.details);
});
});
});

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::VariablesHelper, feature_category: :pipeline_authoring do
describe '#ci_variable_maskable_raw_regex' do
it 'converts to a javascript regex' do
expect(helper.ci_variable_maskable_raw_regex).to eq("^\\S{8,}$")
end
end
end

View File

@ -109,4 +109,68 @@ RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do
expect(subject['info']).to include(expected_info)
end
end
describe '#paginate_candidates' do
let(:page) { 1 }
let(:max_per_page) { 1 }
subject { helper.paginate_candidates(experiment.candidates.order(:id), page, max_per_page) }
it 'paginates' do
expect(subject[1]).not_to be_nil
end
it 'only returns max_per_page elements' do
expect(subject[0].size).to eq(1)
end
it 'fetches the items on the first page' do
expect(subject[0]).to eq([candidate0])
end
it 'creates the pagination info' do
expect(subject[1]).to eq({
page: 1,
is_last_page: false,
per_page: 1,
total_items: 2,
total_pages: 2,
out_of_range: false
})
end
context 'when not the first page' do
let(:page) { 2 }
it 'fetches the right page' do
expect(subject[0]).to eq([candidate1])
end
it 'creates the pagination info' do
expect(subject[1]).to eq({
page: 2,
is_last_page: true,
per_page: 1,
total_items: 2,
total_pages: 2,
out_of_range: false
})
end
end
context 'when out of bounds' do
let(:page) { 3 }
it 'creates the pagination info' do
expect(subject[1]).to eq({
page: page,
is_last_page: false,
per_page: 1,
total_items: 2,
total_pages: 2,
out_of_range: true
})
end
end
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe API::Entities::Ml::Mlflow::RunInfo do
RSpec.describe API::Entities::Ml::Mlflow::RunInfo, feature_category: :mlops do
let_it_be(:candidate) { create(:ml_candidates) }
subject { described_class.new(candidate, packages_url: 'http://example.com').as_json }

View File

@ -30,6 +30,60 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
.to receive(:check_execution_time!)
end
describe '.initialize' do
context 'when a local is specified' do
let(:params) { { local: 'file' } }
it 'sets the location' do
expect(local_file.location).to eq('file')
end
context 'when the local is prefixed with a slash' do
let(:params) { { local: '/file' } }
it 'removes the slash' do
expect(local_file.location).to eq('file')
end
context 'when the FF ci_batch_request_for_local_and_project_includes is disabled' do
before do
stub_feature_flags(ci_batch_request_for_local_and_project_includes: false)
end
it 'does not remove the slash' do
expect(local_file.location).to eq('/file')
end
end
end
context 'when the local is prefixed with multiple slashes' do
let(:params) { { local: '//file' } }
it 'removes slashes' do
expect(local_file.location).to eq('file')
end
context 'when the FF ci_batch_request_for_local_and_project_includes is disabled' do
before do
stub_feature_flags(ci_batch_request_for_local_and_project_includes: false)
end
it 'does not remove slashes' do
expect(local_file.location).to eq('//file')
end
end
end
end
context 'with a missing local' do
let(:params) { { local: nil } }
it 'sets the location to an empty string' do
expect(local_file.location).to eq('')
end
end
end
describe '#matching?' do
context 'when a local is specified' do
let(:params) { { local: 'file' } }
@ -91,7 +145,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
it 'returns false and adds an error message about an empty file' do
allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("")
local_file.validate!
expect(local_file.errors).to include("Local file `/lib/gitlab/ci/templates/xxxxxx/existent-file.yml` is empty!")
expect(local_file.errors).to include("Local file `lib/gitlab/ci/templates/xxxxxx/existent-file.yml` is empty!")
end
end
@ -101,7 +155,18 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
it 'returns false and adds an error message stating that included file does not exist' do
expect(valid?).to be_falsy
expect(local_file.errors).to include("Sha #{sha} is not valid!")
expect(local_file.errors).to include("Local file `lib/gitlab/ci/templates/existent-file.yml` does not exist!")
end
context 'when the FF ci_batch_request_for_local_and_project_includes is disabled' do
before do
stub_feature_flags(ci_batch_request_for_local_and_project_includes: false)
end
it 'returns false and adds an error message stating that sha does not exist' do
expect(valid?).to be_falsy
expect(local_file.errors).to include("Sha #{sha} is not valid!")
end
end
end
end
@ -147,7 +212,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
end
it 'returns an error message' do
expect(local_file.error_message).to eq("Local file `/lib/gitlab/ci/templates/xxxxxxxxxxx.yml` does not exist!")
expect(local_file.error_message).to eq("Local file `lib/gitlab/ci/templates/xxxxxxxxxxx.yml` does not exist!")
end
end
@ -203,7 +268,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
context_project: project.full_path,
context_sha: sha,
type: :local,
location: '/lib/gitlab/ci/templates/existent-file.yml',
location: 'lib/gitlab/ci/templates/existent-file.yml',
blob: "http://localhost/#{project.full_path}/-/blob/#{sha}/lib/gitlab/ci/templates/existent-file.yml",
raw: "http://localhost/#{project.full_path}/-/raw/#{sha}/lib/gitlab/ci/templates/existent-file.yml",
extra: {}

View File

@ -25,6 +25,10 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
my_test:
script: echo Hello World
YAML
'myfolder/file3.yml' => <<~YAML,
my_deploy:
script: echo Hello World
YAML
'nested_configs.yml' => <<~YAML
include:
- local: myfolder/file1.yml
@ -58,16 +62,35 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
let(:files) do
[
Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file1.yml' }, context),
Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file2.yml' }, context)
Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file2.yml' }, context),
Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file3.yml' }, context)
]
end
it 'returns an array of file objects' do
expect(process.map(&:location)).to contain_exactly('myfolder/file1.yml', 'myfolder/file2.yml')
expect(process.map(&:location)).to contain_exactly(
'myfolder/file1.yml', 'myfolder/file2.yml', 'myfolder/file3.yml'
)
end
it 'adds files to the expandset' do
expect { process }.to change { context.expandset.count }.by(2)
expect { process }.to change { context.expandset.count }.by(3)
end
it 'calls Gitaly only once for all files', :request_store do
# 1 for project.commit.id, 1 for the files
expect { process }.to change { Gitlab::GitalyClient.get_request_count }.by(2)
end
context 'when the FF ci_batch_request_for_local_and_project_includes is disabled' do
before do
stub_feature_flags(ci_batch_request_for_local_and_project_includes: false)
end
it 'calls Gitaly for each file', :request_store do
# 1 for project.commit.id, 3 for the files
expect { process }.to change { Gitlab::GitalyClient.get_request_count }.by(4)
end
end
end

View File

@ -2,8 +2,7 @@
require 'spec_helper'
# This will be use with the FF ci_refactoring_external_mapper_verifier in the next MR.
# It can be removed when the FF is removed.
# This will be moved from a `shared_context` to a `describe` once every feature flag is removed.
RSpec.shared_context 'gitlab_ci_config_external_mapper' do
include StubRequests
include RepoHelpers
@ -467,4 +466,12 @@ end
RSpec.describe Gitlab::Ci::Config::External::Mapper, feature_category: :pipeline_authoring do
it_behaves_like 'gitlab_ci_config_external_mapper'
context 'when the FF ci_batch_request_for_local_and_project_includes is disabled' do
before do
stub_feature_flags(ci_batch_request_for_local_and_project_includes: false)
end
it_behaves_like 'gitlab_ci_config_external_mapper'
end
end

View File

@ -52,7 +52,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel
it 'raises an error' do
expect { processor.perform }.to raise_error(
described_class::IncludeError,
"Local file `/lib/gitlab/ci/templates/non-existent-file.yml` does not exist!"
"Local file `lib/gitlab/ci/templates/non-existent-file.yml` does not exist!"
)
end
end
@ -221,7 +221,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel
it 'raises an error' do
expect { processor.perform }.to raise_error(
described_class::IncludeError,
"Included file `/lib/gitlab/ci/templates/template.yml` does not have valid YAML syntax!"
"Included file `lib/gitlab/ci/templates/template.yml` does not have valid YAML syntax!"
)
end
end
@ -313,7 +313,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel
expect(context.includes).to contain_exactly(
{ type: :local,
location: '/local/file.yml',
location: 'local/file.yml',
blob: "http://localhost/#{project.full_path}/-/blob/#{sha}/local/file.yml",
raw: "http://localhost/#{project.full_path}/-/raw/#{sha}/local/file.yml",
extra: {},
@ -341,7 +341,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel
context_project: project.full_path,
context_sha: sha },
{ type: :local,
location: '/templates/my-build.yml',
location: 'templates/my-build.yml',
blob: "http://localhost/#{another_project.full_path}/-/blob/#{another_project.commit.sha}/templates/my-build.yml",
raw: "http://localhost/#{another_project.full_path}/-/raw/#{another_project.commit.sha}/templates/my-build.yml",
extra: {},

View File

@ -1503,7 +1503,7 @@ module Gitlab
end
context "when the included internal file is not present" do
it_behaves_like 'returns errors', "Local file `/local.gitlab-ci.yml` does not exist!"
it_behaves_like 'returns errors', "Local file `local.gitlab-ci.yml` does not exist!"
end
end
end

View File

@ -7,7 +7,8 @@ RSpec.describe Gitlab::Utils do
delegate :to_boolean, :boolean_to_yes_no, :slugify, :which,
:ensure_array_from_string, :to_exclusive_sentence, :bytes_to_megabytes,
:append_path, :check_path_traversal!, :allowlisted?, :check_allowed_absolute_path!, :decode_path, :ms_to_round_sec, :check_allowed_absolute_path_and_path_traversal!, to: :described_class
:append_path, :remove_leading_slashes, :check_path_traversal!, :allowlisted?, :check_allowed_absolute_path!,
:decode_path, :ms_to_round_sec, :check_allowed_absolute_path_and_path_traversal!, to: :described_class
describe '.check_path_traversal!' do
it 'detects path traversal in string without any separators' do
@ -378,6 +379,23 @@ RSpec.describe Gitlab::Utils do
end
end
describe '.remove_leading_slashes' do
where(:str, :result) do
'/foo/bar' | 'foo/bar'
'//foo/bar' | 'foo/bar'
'/foo/bar/' | 'foo/bar/'
'foo/bar' | 'foo/bar'
'' | ''
nil | ''
end
with_them do
it 'removes leading slashes' do
expect(remove_leading_slashes(str)).to eq(result)
end
end
end
describe '.ensure_utf8_size' do
context 'string is has less bytes than expected' do
it 'backfills string with null characters' do

View File

@ -2,15 +2,15 @@
require 'spec_helper'
RSpec.describe Ci::Maskable do
RSpec.describe Ci::Maskable, feature_category: :pipeline_authoring do
let(:variable) { build(:ci_variable) }
describe 'masked value validations' do
subject { variable }
context 'when variable is masked' do
context 'when variable is masked and expanded' do
before do
subject.masked = true
subject.update!(masked: true, raw: false)
end
it { is_expected.not_to allow_value('hello').for(:value) }
@ -20,6 +20,53 @@ RSpec.describe Ci::Maskable do
it { is_expected.to allow_value('helloworld').for(:value) }
end
context 'when method :raw is not defined' do
let(:test_var_class) do
Struct.new(:masked?) do
include ActiveModel::Validations
include Ci::Maskable
end
end
let(:variable) { test_var_class.new }
it 'evaluates masked variables as expanded' do
expect(subject).not_to be_masked_and_raw
expect(subject).to be_masked_and_expanded
end
end
context 'when the ci_remove_character_limitation_raw_masked_var FF is disabled' do
context 'when variable is masked and raw' do
before do
subject.update!(masked: true, raw: true)
stub_feature_flags(ci_remove_character_limitation_raw_masked_var: false)
end
it { is_expected.not_to allow_value('hello').for(:value) }
it { is_expected.not_to allow_value('hello world').for(:value) }
it { is_expected.not_to allow_value('hello$VARIABLEworld').for(:value) }
it { is_expected.not_to allow_value('hello\rworld').for(:value) }
it { is_expected.not_to allow_value('hello&&&world').for(:value) }
it { is_expected.not_to allow_value('helloworld!!!!').for(:value) }
it { is_expected.to allow_value('helloworld').for(:value) }
end
end
context 'when variable is masked and raw' do
before do
subject.update!(masked: true, raw: true)
end
it { is_expected.not_to allow_value('hello').for(:value) }
it { is_expected.not_to allow_value('hello world').for(:value) }
it { is_expected.to allow_value('hello\rworld').for(:value) }
it { is_expected.to allow_value('hello$VARIABLEworld').for(:value) }
it { is_expected.to allow_value('helloworld!!!').for(:value) }
it { is_expected.to allow_value('hell******world').for(:value) }
it { is_expected.to allow_value('helloworld123').for(:value) }
end
context 'when variable is not masked' do
before do
subject.masked = false
@ -33,40 +80,70 @@ RSpec.describe Ci::Maskable do
end
end
describe 'REGEX' do
subject { Ci::Maskable::REGEX }
describe 'Regexes' do
context 'with MASK_AND_RAW_REGEX' do
subject { Ci::Maskable::MASK_AND_RAW_REGEX }
it 'does not match strings shorter than 8 letters' do
expect(subject.match?('hello')).to eq(false)
it 'does not match strings shorter than 8 letters' do
expect(subject.match?('hello')).to eq(false)
end
it 'does not match strings with spaces' do
expect(subject.match?('hello world')).to eq(false)
end
it 'does not match strings that span more than one line' do
string = <<~EOS
hello
world
EOS
expect(subject.match?(string)).to eq(false)
end
it 'matches valid strings' do
expect(subject.match?('hello$VARIABLEworld')).to eq(true)
expect(subject.match?('Hello+World_123/@:-~.')).to eq(true)
expect(subject.match?('hello\rworld')).to eq(true)
expect(subject.match?('HelloWorld%#^')).to eq(true)
end
end
it 'does not match strings with spaces' do
expect(subject.match?('hello world')).to eq(false)
end
context 'with REGEX' do
subject { Ci::Maskable::REGEX }
it 'does not match strings with shell variables' do
expect(subject.match?('hello$VARIABLEworld')).to eq(false)
end
it 'does not match strings shorter than 8 letters' do
expect(subject.match?('hello')).to eq(false)
end
it 'does not match strings with escape characters' do
expect(subject.match?('hello\rworld')).to eq(false)
end
it 'does not match strings with spaces' do
expect(subject.match?('hello world')).to eq(false)
end
it 'does not match strings that span more than one line' do
string = <<~EOS
hello
world
EOS
it 'does not match strings with shell variables' do
expect(subject.match?('hello$VARIABLEworld')).to eq(false)
end
expect(subject.match?(string)).to eq(false)
end
it 'does not match strings with escape characters' do
expect(subject.match?('hello\rworld')).to eq(false)
end
it 'does not match strings using unsupported characters' do
expect(subject.match?('HelloWorld%#^')).to eq(false)
end
it 'does not match strings that span more than one line' do
string = <<~EOS
hello
world
EOS
it 'matches valid strings' do
expect(subject.match?('Hello+World_123/@:-~.')).to eq(true)
expect(subject.match?(string)).to eq(false)
end
it 'does not match strings using unsupported characters' do
expect(subject.match?('HelloWorld%#^')).to eq(false)
end
it 'matches valid strings' do
expect(subject.match?('Hello+World_123/@:-~.')).to eq(true)
end
end
end

View File

@ -36,8 +36,16 @@ RSpec.describe IncidentManagement::TimelineEventTag do
end
describe 'constants' do
it { expect(described_class::START_TIME_TAG_NAME).to eq('Start time') }
it { expect(described_class::END_TIME_TAG_NAME).to eq('End time') }
it 'contains predefined tags' do
expect(described_class::PREDEFINED_TAGS).to contain_exactly(
'Start time',
'End time',
'Impact detected',
'Response initiated',
'Impact mitigated',
'Cause identified'
)
end
end
describe '#by_names scope' do

View File

@ -2,9 +2,11 @@
require 'spec_helper'
RSpec.describe Ml::Candidate, factory_default: :keep do
let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params) }
let_it_be(:candidate2) { create(:ml_candidates, experiment: candidate.experiment) }
RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops do
let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params, name: 'candidate0') }
let_it_be(:candidate2) do
create(:ml_candidates, experiment: candidate.experiment, user: create(:user), name: 'candidate2')
end
let_it_be(:candidate_artifact) do
FactoryBot.create(:generic_package,
@ -109,12 +111,12 @@ RSpec.describe Ml::Candidate, factory_default: :keep do
end
describe "#latest_metrics" do
let_it_be(:candidate2) { create(:ml_candidates, experiment: candidate.experiment) }
let!(:metric1) { create(:ml_candidate_metrics, candidate: candidate2) }
let!(:metric2) { create(:ml_candidate_metrics, candidate: candidate2 ) }
let!(:metric3) { create(:ml_candidate_metrics, name: metric1.name, candidate: candidate2) }
let_it_be(:candidate3) { create(:ml_candidates, experiment: candidate.experiment) }
let_it_be(:metric1) { create(:ml_candidate_metrics, candidate: candidate3) }
let_it_be(:metric2) { create(:ml_candidate_metrics, candidate: candidate3 ) }
let_it_be(:metric3) { create(:ml_candidate_metrics, name: metric1.name, candidate: candidate3) }
subject { candidate2.latest_metrics }
subject { candidate3.latest_metrics }
it 'fetches only the last metric for the name' do
expect(subject).to match_array([metric2, metric3] )
@ -130,4 +132,55 @@ RSpec.describe Ml::Candidate, factory_default: :keep do
expect(subject.association_cached?(:user)).to be(true)
end
end
describe '#by_name' do
let(:name) { candidate.name }
subject { described_class.by_name(name) }
context 'when name matches' do
it 'gets the correct candidates' do
expect(subject).to match_array([candidate])
end
end
context 'when name matches partially' do
let(:name) { 'andidate' }
it 'gets the correct candidates' do
expect(subject).to match_array([candidate, candidate2])
end
end
context 'when name does not match' do
let(:name) { non_existing_record_id.to_s }
it 'does not fetch any candidate' do
expect(subject).to match_array([])
end
end
end
describe '#order_by_metric' do
let_it_be(:auc_metrics) do
create(:ml_candidate_metrics, name: 'auc', value: 0.4, candidate: candidate)
create(:ml_candidate_metrics, name: 'auc', value: 0.8, candidate: candidate2)
end
let(:direction) { 'desc' }
subject { described_class.order_by_metric('auc', direction) }
it 'orders correctly' do
expect(subject).to eq([candidate2, candidate])
end
context 'when direction is asc' do
let(:direction) { 'asc' }
it 'orders correctly' do
expect(subject).to eq([candidate, candidate2])
end
end
end
end

View File

@ -98,7 +98,7 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do
let(:params) { basic_params.merge(id: experiment.iid, page: 's') }
it 'uses first page' do
expect(assigns(:pagination)).to include(
expect(assigns(:pagination_info)).to include(
page: 1,
is_last_page: false,
per_page: 2,
@ -108,6 +108,32 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do
end
end
describe 'search' do
let(:params) do
basic_params.merge(
id: experiment.iid,
name: 'some_name',
orderBy: 'name',
orderByType: 'metric',
sort: 'asc',
invalid: 'invalid'
)
end
it 'formats and filters the parameters' do
expect(Projects::Ml::CandidateFinder).to receive(:new).and_call_original do |exp, params|
expect(params.to_h).to include({
name: 'some_name',
order_by: 'name',
order_by_type: 'metric',
sort: 'asc'
})
end
show_experiment
end
end
it 'does not perform N+1 sql queries' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment }

View File

@ -171,7 +171,7 @@ RSpec.describe IncidentManagement::TimelineEvents::CreateService do
occurred_at: Time.current,
action: 'new comment',
promoted_from_note: comment,
timeline_event_tag_names: ['start time', 'end time']
timeline_event_tag_names: ['start time', 'end time', 'Impact mitigated']
}
end
@ -180,11 +180,11 @@ RSpec.describe IncidentManagement::TimelineEvents::CreateService do
it 'matches the two tags on the event and creates on project' do
result = execute.payload[:timeline_event]
expect(result.timeline_event_tags.count).to eq(2)
expect(result.timeline_event_tags.by_names(['Start time', 'End time']).pluck_names)
.to match_array(['Start time', 'End time'])
expect(result.timeline_event_tags.count).to eq(3)
expect(result.timeline_event_tags.by_names(['Start time', 'End time', 'Impact mitigated']).pluck_names)
.to match_array(['Start time', 'End time', 'Impact mitigated'])
expect(project.incident_management_timeline_event_tags.pluck_names)
.to include('Start time', 'End time')
.to include('Start time', 'End time', 'Impact mitigated')
end
end

View File

@ -201,20 +201,22 @@ RSpec.describe IncidentManagement::TimelineEvents::UpdateService, feature_catego
{
note: 'Updated note',
occurred_at: occurred_at,
timeline_event_tag_names: ['start time', 'end time']
timeline_event_tag_names: ['start time', 'end time', 'response initiated']
}
end
it 'returns the new tag in response' do
timeline_event = execute.payload[:timeline_event]
expect(timeline_event.timeline_event_tags.pluck_names).to contain_exactly('Start time', 'End time')
expect(timeline_event.timeline_event_tags.pluck_names).to contain_exactly(
'Start time', 'End time', 'Response initiated')
end
it 'creates the predefined tags on the project' do
execute
expect(project.incident_management_timeline_event_tags.pluck_names).to include('Start time', 'End time')
expect(project.incident_management_timeline_event_tags.pluck_names).to include(
'Start time', 'End time', 'Response initiated')
end
end

View File

@ -93,13 +93,10 @@ RSpec.describe Projects::PostCreationWorker do
context 'when project is created', :aggregate_failures do
it 'creates tags for the project' do
expect { subject }.to change { IncidentManagement::TimelineEventTag.count }.by(2)
expect { subject }.to change { IncidentManagement::TimelineEventTag.count }.by(6)
expect(project.incident_management_timeline_event_tags.pluck_names).to match_array(
[
::IncidentManagement::TimelineEventTag::START_TIME_TAG_NAME,
::IncidentManagement::TimelineEventTag::END_TIME_TAG_NAME
]
::IncidentManagement::TimelineEventTag::PREDEFINED_TAGS
)
end