Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
0121231095
commit
ff89c3c372
|
@ -2205,7 +2205,7 @@ Gitlab/NamespacedClass:
|
|||
- 'app/validators/system_hook_url_validator.rb'
|
||||
- 'app/validators/top_level_group_validator.rb'
|
||||
- 'app/validators/untrusted_regexp_validator.rb'
|
||||
- 'app/validators/variable_duplicates_validator.rb'
|
||||
- 'app/validators/nested_attributes_duplicates_validator.rb'
|
||||
- 'app/validators/x509_certificate_credentials_validator.rb'
|
||||
- 'app/validators/zoom_url_validator.rb'
|
||||
- 'app/workers/admin_email_worker.rb'
|
||||
|
|
|
@ -1 +1 @@
|
|||
99f78e4d93d8c9ec23ef710ffde0fb4b75d786bb
|
||||
627c53e3e51f73c3d19df2b49b956c02ba200e78
|
||||
|
|
|
@ -26,8 +26,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="info-well">
|
||||
<div class="well-segment admin-well admin-well-statistics">
|
||||
<div class="gl-card">
|
||||
<div class="gl-card-body">
|
||||
<h4>{{ __('Statistics') }}</h4>
|
||||
<gl-loading-icon v-if="isLoading" size="md" class="my-3" />
|
||||
<template v-else>
|
||||
|
|
|
@ -1,10 +1,3 @@
|
|||
.info-well {
|
||||
.admin-well-statistics,
|
||||
.admin-well-features {
|
||||
padding-bottom: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-data {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
|
|
@ -319,9 +319,7 @@ class GroupsController < Groups::ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def successful_creation_hooks
|
||||
track_experiment_event(:onboarding_issues, 'created_namespace')
|
||||
end
|
||||
def successful_creation_hooks; end
|
||||
|
||||
def groups
|
||||
if @group.supports_events?
|
||||
|
|
|
@ -14,7 +14,6 @@ module Registrations
|
|||
|
||||
if current_user.save
|
||||
hide_advanced_issues
|
||||
record_experiment_user(:default_to_issues_board)
|
||||
|
||||
if experiment_enabled?(:default_to_issues_board) && learn_gitlab.available?
|
||||
redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board)
|
||||
|
|
|
@ -39,9 +39,6 @@ module Registrations
|
|||
def process_gitlab_com_tracking
|
||||
return false unless ::Gitlab.com?
|
||||
return false unless show_onboarding_issues_experiment?
|
||||
|
||||
track_experiment_event(:onboarding_issues, 'signed_up')
|
||||
record_experiment_user(:onboarding_issues)
|
||||
end
|
||||
|
||||
def update_params
|
||||
|
|
|
@ -21,7 +21,7 @@ module Ci
|
|||
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
|
||||
validates :ref, presence: { unless: :importing? }
|
||||
validates :description, presence: true
|
||||
validates :variables, variable_duplicates: true
|
||||
validates :variables, nested_attributes_duplicates: true
|
||||
|
||||
strip_attributes :cron
|
||||
|
||||
|
|
|
@ -84,7 +84,7 @@ class Group < Namespace
|
|||
validate :visibility_level_allowed_by_sub_groups
|
||||
validate :visibility_level_allowed_by_parent
|
||||
validate :two_factor_authentication_allowed
|
||||
validates :variables, variable_duplicates: true
|
||||
validates :variables, nested_attributes_duplicates: true
|
||||
|
||||
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
|
|
|
@ -456,7 +456,7 @@ class Project < ApplicationRecord
|
|||
validates :repository_storage,
|
||||
presence: true,
|
||||
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
|
||||
validates :variables, variable_duplicates: { scope: :environment_scope }
|
||||
validates :variables, nested_attributes_duplicates: { scope: :environment_scope }
|
||||
validates :bfg_object_map, file_size: { maximum: :max_attachment_size }
|
||||
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
|
||||
|
||||
|
|
|
@ -11,5 +11,5 @@ class ProjectPagesMetadatum < ApplicationRecord
|
|||
|
||||
scope :deployed, -> { where(deployed: true) }
|
||||
scope :only_on_legacy_storage, -> { deployed.where(pages_deployment: nil) }
|
||||
scope :with_project_route_and_deployment, -> { preload(project: [:namespace, :route, pages_metadatum: :pages_deployment]) }
|
||||
scope :with_project_route_and_deployment, -> { preload(:pages_deployment, project: [:namespace, :route]) }
|
||||
end
|
||||
|
|
|
@ -24,6 +24,7 @@ class Release < ApplicationRecord
|
|||
|
||||
validates :project, :tag, presence: true
|
||||
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
|
||||
validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] }
|
||||
|
||||
scope :sorted, -> { order(released_at: :desc) }
|
||||
scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) }
|
||||
|
|
|
@ -1059,6 +1059,10 @@ class Repository
|
|||
blob_data_at(sha, '.lfsconfig')
|
||||
end
|
||||
|
||||
def changelog_config(ref = 'HEAD')
|
||||
blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH)
|
||||
end
|
||||
|
||||
def fetch_ref(source_repository, source_ref:, target_ref:)
|
||||
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
|
||||
end
|
||||
|
|
|
@ -32,12 +32,14 @@ module Pages
|
|||
def start_migration_threads
|
||||
Array.new(@migration_threads) do
|
||||
Thread.new do
|
||||
Rails.application.executor.wrap do
|
||||
while batch = @queue.pop
|
||||
process_batch(batch)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def process_batch(batch)
|
||||
batch.with_project_route_and_deployment.each do |metadatum|
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
module Suggestions
|
||||
class ApplyService < ::BaseService
|
||||
def initialize(current_user, *suggestions)
|
||||
def initialize(current_user, *suggestions, message: nil)
|
||||
@current_user = current_user
|
||||
@message = message
|
||||
@suggestion_set = Gitlab::Suggestions::SuggestionSet.new(suggestions)
|
||||
end
|
||||
|
||||
|
@ -47,7 +48,7 @@ module Suggestions
|
|||
end
|
||||
|
||||
def commit_message
|
||||
Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message
|
||||
Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set, @message).message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# VariableDuplicatesValidator
|
||||
# NestedAttributesDuplicates
|
||||
#
|
||||
# This validator is designed for especially the following condition
|
||||
# - Use `accepts_nested_attributes_for :xxx` in a parent model
|
||||
# - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model
|
||||
class VariableDuplicatesValidator < ActiveModel::EachValidator
|
||||
class NestedAttributesDuplicatesValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
return if record.errors.include?(:"#{attribute}.key")
|
||||
return if child_attributes.any? { |child_attribute| record.errors.include?(:"#{attribute}.#{child_attribute}") }
|
||||
|
||||
if options[:scope]
|
||||
scoped = value.group_by do |variable|
|
||||
|
@ -23,12 +23,18 @@ class VariableDuplicatesValidator < ActiveModel::EachValidator
|
|||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def validate_duplicates(record, attribute, values)
|
||||
duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
|
||||
child_attributes.each do |child_attribute|
|
||||
duplicates = values.reject(&:marked_for_destruction?).group_by(&:"#{child_attribute}").select { |_, v| v.many? }.map(&:first)
|
||||
if duplicates.any?
|
||||
error_message = +"have duplicate values (#{duplicates.join(", ")})"
|
||||
error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend
|
||||
record.errors.add(attribute, error_message)
|
||||
end
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def child_attributes
|
||||
options[:child_attributes] || %i[key]
|
||||
end
|
||||
end
|
|
@ -15,49 +15,63 @@
|
|||
= render_if_exists 'admin/licenses/breakdown'
|
||||
|
||||
.admin-dashboard.gl-mt-3
|
||||
.h3.gl-mb-5.gl-mt-0= _('Instance overview')
|
||||
.row
|
||||
.col-sm-4
|
||||
.info-well.dark-well.flex-fill
|
||||
.well-segment.well-centered
|
||||
= link_to admin_projects_path do
|
||||
%h3.text-center
|
||||
= s_('AdminArea|Projects: %{number_of_projects}') % { number_of_projects: approximate_count_with_delimiters(@counts, Project) }
|
||||
%hr
|
||||
= link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-success gl-w-full")
|
||||
.col-sm-4
|
||||
.info-well.dark-well
|
||||
.well-segment.well-centered.gl-text-center
|
||||
= link_to admin_users_path do
|
||||
%h3.gl-display-inline-block.gl-mb-0
|
||||
= s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) }
|
||||
|
||||
%span.gl-outline-0.gl-ml-2{ href: "#", tabindex: "0", data: { container: "body",
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6
|
||||
%span
|
||||
.d-flex.align-items-center
|
||||
= sprite_icon('project', size: 16, css_class: 'gl-text-gray-700')
|
||||
%h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Project)
|
||||
.gl-mt-3.text-uppercase= s_('AdminArea|Projects')
|
||||
= link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-default")
|
||||
.gl-card-footer.gl-bg-transparent
|
||||
.d-flex.align-items-center
|
||||
= link_to(s_('AdminArea|View latest projects'), admin_projects_path)
|
||||
= sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6
|
||||
%span
|
||||
.d-flex.align-items-center
|
||||
= sprite_icon('users', size: 16, css_class: 'gl-text-gray-700')
|
||||
%h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, User)
|
||||
%span.gl-outline-0.gl-ml-3{ tabindex: "0", data: { container: "body",
|
||||
toggle: "popover",
|
||||
placement: "top",
|
||||
html: "true",
|
||||
trigger: "focus",
|
||||
content: s_("AdminArea|All users created in the instance, including users who are not %{billable_users_link_start}billable users%{billable_users_link_end}.").html_safe % { billable_users_link_start: billable_users_link_start, billable_users_link_end: '</a>'.html_safe },
|
||||
} }
|
||||
= sprite_icon('question', size: 16, css_class: 'gl-text-gray-700 gl-mb-1')
|
||||
|
||||
%hr
|
||||
.btn-group.d-flex{ role: 'group' }
|
||||
= link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full"
|
||||
= link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn gl-button btn-info gl-w-full'
|
||||
.col-sm-4
|
||||
.info-well.dark-well
|
||||
.well-segment.well-centered
|
||||
= link_to admin_groups_path do
|
||||
%h3.text-center
|
||||
= s_('AdminArea|Groups: %{number_of_groups}') % { number_of_groups: approximate_count_with_delimiters(@counts, Group) }
|
||||
%hr
|
||||
= link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-success gl-w-full"
|
||||
= sprite_icon('question', size: 16, css_class: 'gl-text-gray-700')
|
||||
.gl-mt-3.text-uppercase
|
||||
= s_('AdminArea|Users')
|
||||
= link_to(s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: "text-capitalize gl-ml-2")
|
||||
= link_to(s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-default")
|
||||
.gl-card-footer.gl-bg-transparent
|
||||
.d-flex.align-items-center
|
||||
= link_to(s_('AdminArea|View latest users'), admin_users_path)
|
||||
= sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6
|
||||
%span
|
||||
.d-flex.align-items-center
|
||||
= sprite_icon('group', size: 16, css_class: 'gl-text-gray-700')
|
||||
%h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Group)
|
||||
.gl-mt-3.text-uppercase= s_('AdminArea|Projects')
|
||||
= link_to(s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-default")
|
||||
.gl-card-footer.gl-bg-transparent
|
||||
.d-flex.align-items-center
|
||||
= link_to(s_('AdminArea|View latest groups'), admin_groups_path)
|
||||
= sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
|
||||
.row
|
||||
.col-md-4
|
||||
.col-md-4.gl-mb-6
|
||||
#js-admin-statistics-container
|
||||
.col-md-4
|
||||
.info-well
|
||||
.well-segment.admin-well.admin-well-features
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body
|
||||
%h4= s_('AdminArea|Features')
|
||||
= feature_entry(_('Sign up'),
|
||||
href: general_admin_application_settings_path(anchor: 'js-signup-settings'),
|
||||
|
@ -94,9 +108,9 @@
|
|||
= feature_entry(_('Shared Runners'),
|
||||
href: admin_runners_path,
|
||||
enabled: Gitlab.config.gitlab_ci.shared_runners_enabled)
|
||||
.col-md-4
|
||||
.info-well
|
||||
.well-segment.admin-well
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body
|
||||
%h4
|
||||
= s_('AdminArea|Components')
|
||||
- if Gitlab::CurrentSettings.version_check_enabled
|
||||
|
@ -146,18 +160,18 @@
|
|||
%p
|
||||
= link_to _("Gitaly Servers"), admin_gitaly_servers_path
|
||||
.row
|
||||
.col-md-4
|
||||
.info-well
|
||||
.well-segment.admin-well
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body
|
||||
%h4= s_('AdminArea|Latest projects')
|
||||
- @projects.each do |project|
|
||||
%p
|
||||
= link_to project.full_name, admin_project_path(project), class: 'str-truncated-60'
|
||||
%span.light.float-right
|
||||
#{time_ago_with_tooltip(project.created_at)}
|
||||
.col-md-4
|
||||
.info-well
|
||||
.well-segment.admin-well
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body
|
||||
%h4= s_('AdminArea|Latest users')
|
||||
- @users.each do |user|
|
||||
%p
|
||||
|
@ -165,9 +179,9 @@
|
|||
= user.name
|
||||
%span.light.float-right
|
||||
#{time_ago_with_tooltip(user.created_at)}
|
||||
.col-md-4
|
||||
.info-well
|
||||
.well-segment.admin-well
|
||||
.col-md-4.gl-mb-6
|
||||
.gl-card
|
||||
.gl-card-body
|
||||
%h4= s_('AdminArea|Latest groups')
|
||||
- @groups.each do |group|
|
||||
%p
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
- if current_user_menu?(:start_trial)
|
||||
%li
|
||||
%a.trial-link{ href: trials_link_url }
|
||||
= s_("CurrentUser|Start a Gold trial")
|
||||
= s_("CurrentUser|Start an Ultimate trial")
|
||||
= emoji_icon('rocket')
|
||||
- if current_user_menu?(:settings)
|
||||
%li
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add a commit message parameter for the suggestion endpoints
|
||||
merge_request: 51245
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Improve duplication validation on Release Links
|
||||
merge_request: 51951
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Admin dashboard basic stats redesign
|
||||
merge_request: 52176
|
||||
author: Yogi (@yo)
|
||||
type: changed
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
stage: Enablement
|
||||
group: Memory
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Changing application settings cache expiry interval **(CORE ONLY)**
|
||||
|
||||
Application settings are cached for 60 seconds by default which should work
|
||||
for most installations. A higher value would mean a greater delay between
|
||||
changing an application setting and noticing that change come into effect.
|
||||
A value of `0` would result in the `application_settings` table being
|
||||
loaded for every request causing extra load on Redis and/or PostgreSQL.
|
||||
It is therefore recommended to keep the value above zero.
|
||||
|
||||
## Change the application settings cache expiry
|
||||
|
||||
To change the expiry value:
|
||||
|
||||
**For Omnibus installations**
|
||||
|
||||
1. Edit `/etc/gitlab/gitlab.rb`:
|
||||
|
||||
```ruby
|
||||
gitlab_rails['application_settings_cache_seconds'] = 60
|
||||
```
|
||||
|
||||
1. Save the file, and reconfigure and restart GitLab for the changes to take effect:
|
||||
|
||||
```shell
|
||||
gitlab-ctl reconfigure
|
||||
gitlab-ctl restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**For installations from source**
|
||||
|
||||
1. Edit `config/gitlab.yml`:
|
||||
|
||||
```yaml
|
||||
gitlab:
|
||||
application_settings_cache_seconds: 60
|
||||
```
|
||||
|
||||
1. Save the file and [restart](restart_gitlab.md#installations-from-source)
|
||||
GitLab for the changes to take effect.
|
|
@ -80,6 +80,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
|
|||
emails with S/MIME.
|
||||
- [Enabling and disabling features flags](feature_flags.md): how to enable and
|
||||
disable GitLab features deployed behind feature flags.
|
||||
- [Application settings cache expiry interval](application_settings_cache.md)
|
||||
|
||||
#### Customizing GitLab appearance
|
||||
|
||||
|
|
|
@ -112,7 +112,24 @@ _The uploads are stored by default in
|
|||
```
|
||||
|
||||
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
|
||||
1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
|
||||
1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md).
|
||||
1. Optional: Verify all files migrated properly.
|
||||
From [PostgreSQL console](https://docs.gitlab.com/omnibus/settings/database.html#connecting-to-the-bundled-postgresql-database)
|
||||
(`sudo gitlab-psql -d gitlabhq_production`) verify `objectstg` below (where `store=2`) has count of all artifacts:
|
||||
|
||||
```shell
|
||||
gitlabhq_production=# SELECT count(*) AS total, sum(case when store = '1' then 1 else 0 end) AS filesystem, sum(case when store = '2' then 1 else 0 end) AS objectstg FROM uploads;
|
||||
|
||||
total | filesystem | objectstg
|
||||
------+------------+-----------
|
||||
2409 | 0 | 2409
|
||||
```
|
||||
|
||||
Verify no files on disk in `artifacts` folder:
|
||||
|
||||
```shell
|
||||
sudo find /var/opt/gitlab/gitlab-rails/uploads -type f | grep -v tmp | wc -l
|
||||
```
|
||||
|
||||
**In installations from source:**
|
||||
|
||||
|
@ -136,6 +153,22 @@ _The uploads are stored by default in
|
|||
|
||||
1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes to take effect.
|
||||
1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
|
||||
1. Optional: Verify all files migrated properly.
|
||||
From PostgreSQL console (`sudo -u git -H psql -d gitlabhq_production`) verify `objectstg` below (where `file_store=2`) has count of all artifacts:
|
||||
|
||||
```shell
|
||||
gitlabhq_production=# SELECT count(*) AS total, sum(case when store = '1' then 1 else 0 end) AS filesystem, sum(case when store = '2' then 1 else 0 end) AS objectstg FROM uploads;
|
||||
|
||||
total | filesystem | objectstg
|
||||
------+------------+-----------
|
||||
2409 | 0 | 2409
|
||||
```
|
||||
|
||||
Verify no files on disk in `artifacts` folder:
|
||||
|
||||
```shell
|
||||
sudo find /var/opt/gitlab/gitlab-rails/uploads -type f | grep -v tmp | wc -l
|
||||
```
|
||||
|
||||
#### OpenStack example
|
||||
|
||||
|
@ -162,6 +195,23 @@ _The uploads are stored by default in
|
|||
|
||||
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
|
||||
1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
|
||||
1. Optional: Verify all files migrated properly.
|
||||
From [PostgreSQL console](https://docs.gitlab.com/omnibus/settings/database.html#connecting-to-the-bundled-postgresql-database)
|
||||
(`sudo gitlab-psql -d gitlabhq_production`) verify `objectstg` below (where `store=2`) has count of all artifacts:
|
||||
|
||||
```shell
|
||||
gitlabhq_production=# SELECT count(*) AS total, sum(case when store = '1' then 1 else 0 end) AS filesystem, sum(case when store = '2' then 1 else 0 end) AS objectstg FROM uploads;
|
||||
|
||||
total | filesystem | objectstg
|
||||
------+------------+-----------
|
||||
2409 | 0 | 2409
|
||||
```
|
||||
|
||||
Verify no files on disk in `artifacts` folder:
|
||||
|
||||
```shell
|
||||
sudo find /var/opt/gitlab/gitlab-rails/uploads -type f | grep -v tmp | wc -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
@ -193,3 +243,19 @@ _The uploads are stored by default in
|
|||
|
||||
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
|
||||
1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
|
||||
1. Optional: Verify all files migrated properly.
|
||||
From PostgreSQL console (`sudo -u git -H psql -d gitlabhq_production`) verify `objectstg` below (where `file_store=2`) has count of all artifacts:
|
||||
|
||||
```shell
|
||||
gitlabhq_production=# SELECT count(*) AS total, sum(case when store = '1' then 1 else 0 end) AS filesystem, sum(case when store = '2' then 1 else 0 end) AS objectstg FROM uploads;
|
||||
|
||||
total | filesystem | objectstg
|
||||
------+------------+-----------
|
||||
2409 | 0 | 2409
|
||||
```
|
||||
|
||||
Verify no files on disk in `artifacts` folder:
|
||||
|
||||
```shell
|
||||
sudo find /var/opt/gitlab/gitlab-rails/uploads -type f | grep -v tmp | wc -l
|
||||
```
|
||||
|
|
|
@ -21,6 +21,7 @@ PUT /suggestions/:id/apply
|
|||
| Attribute | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `id` | integer/string | yes | The ID of a suggestion |
|
||||
| `commit_message` | string | no | A custom commit message to use instead of the default generated message or the project's default message |
|
||||
|
||||
```shell
|
||||
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/suggestions/5/apply"
|
||||
|
|
|
@ -543,6 +543,88 @@ test += " world"
|
|||
When adding new Ruby files, please check that you can add the above header,
|
||||
as omitting it may lead to style check failures.
|
||||
|
||||
## Banzai pipelines and filters
|
||||
|
||||
When writing or updating [Banzai filters and pipelines](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/banzai),
|
||||
it can be difficult to understand what the performance of the filter is, and what effect it might
|
||||
have on the overall pipeline performance.
|
||||
|
||||
To perform benchmarks run:
|
||||
|
||||
```shell
|
||||
bin/rake benchmark:banzai
|
||||
```
|
||||
|
||||
This command generates output like this:
|
||||
|
||||
```plaintext
|
||||
--> Benchmarking Full, Wiki, and Plain pipelines
|
||||
Calculating -------------------------------------
|
||||
Full pipeline 1.000 i/100ms
|
||||
Wiki pipeline 1.000 i/100ms
|
||||
Plain pipeline 1.000 i/100ms
|
||||
-------------------------------------------------
|
||||
Full pipeline 3.357 (±29.8%) i/s - 31.000
|
||||
Wiki pipeline 2.893 (±34.6%) i/s - 25.000 in 10.677014s
|
||||
Plain pipeline 15.447 (±32.4%) i/s - 119.000
|
||||
|
||||
Comparison:
|
||||
Plain pipeline: 15.4 i/s
|
||||
Full pipeline: 3.4 i/s - 4.60x slower
|
||||
Wiki pipeline: 2.9 i/s - 5.34x slower
|
||||
|
||||
.
|
||||
--> Benchmarking FullPipeline filters
|
||||
Calculating -------------------------------------
|
||||
Markdown 24.000 i/100ms
|
||||
Plantuml 8.000 i/100ms
|
||||
SpacedLink 22.000 i/100ms
|
||||
|
||||
...
|
||||
|
||||
TaskList 49.000 i/100ms
|
||||
InlineDiff 9.000 i/100ms
|
||||
SetDirection 369.000 i/100ms
|
||||
-------------------------------------------------
|
||||
Markdown 237.796 (±16.4%) i/s - 2.304k
|
||||
Plantuml 80.415 (±36.1%) i/s - 520.000
|
||||
SpacedLink 168.188 (±10.1%) i/s - 1.672k
|
||||
|
||||
...
|
||||
|
||||
TaskList 101.145 (± 6.9%) i/s - 1.029k
|
||||
InlineDiff 52.925 (±15.1%) i/s - 522.000
|
||||
SetDirection 3.728k (±17.2%) i/s - 34.317k in 10.617882s
|
||||
|
||||
Comparison:
|
||||
Suggestion: 739616.9 i/s
|
||||
Kroki: 306449.0 i/s - 2.41x slower
|
||||
InlineGrafanaMetrics: 156535.6 i/s - 4.72x slower
|
||||
SetDirection: 3728.3 i/s - 198.38x slower
|
||||
|
||||
...
|
||||
|
||||
UserReference: 2.1 i/s - 360365.80x slower
|
||||
ExternalLink: 1.6 i/s - 470400.67x slower
|
||||
ProjectReference: 0.7 i/s - 1128756.09x slower
|
||||
|
||||
.
|
||||
--> Benchmarking PlainMarkdownPipeline filters
|
||||
Calculating -------------------------------------
|
||||
Markdown 19.000 i/100ms
|
||||
-------------------------------------------------
|
||||
Markdown 241.476 (±15.3%) i/s - 2.356k
|
||||
|
||||
```
|
||||
|
||||
This can give you an idea how various filters perform, and which ones might be performing the slowest.
|
||||
|
||||
The test data has a lot to do with how well a filter performs. If there is nothing in the test data
|
||||
that specifically triggers the filter, it might look like it's running incredibly fast.
|
||||
Make sure that you have relevant test data for your filter in the
|
||||
[`spec/fixtures/markdown.md.erb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/fixtures/markdown.md.erb)
|
||||
file.
|
||||
|
||||
## Reading from files and other data sources
|
||||
|
||||
Ruby offers several convenience functions that deal with file contents specifically
|
||||
|
|
|
@ -767,12 +767,14 @@ describe '#show', :snowplow do
|
|||
expect_snowplow_event(
|
||||
category: 'Experiment',
|
||||
action: 'start',
|
||||
standard_context: { namespace: group, project: project }
|
||||
)
|
||||
expect_snowplow_event(
|
||||
category: 'Experiment',
|
||||
action: 'sent',
|
||||
property: 'property',
|
||||
label: 'label'
|
||||
label: 'label',
|
||||
standard_context: { namespace: group, project: project }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -77,7 +77,20 @@ in the **Authorized applications** section under **Profile Settings > Applicatio
|
|||
![Authorized_applications](img/oauth_provider_authorized_application.png)
|
||||
|
||||
The GitLab OAuth applications support scopes, which allow various actions that any given
|
||||
application can perform such as `read_user` and `api`. There are many more scopes
|
||||
available.
|
||||
application can perform. The available scopes are depicted in the following table.
|
||||
|
||||
| Scope | Description |
|
||||
| ------------------ | ----------- |
|
||||
| `api` | Grants complete read/write access to the API, including all groups and projects, the container registry, and the package registry. |
|
||||
| `read_user` | Grants read-only access to the authenticated user's profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users. |
|
||||
| `read_api` | Grants read access to the API, including all groups and projects, the container registry, and the package registry. |
|
||||
| `read_repository` | Grants read-only access to repositories on private projects using Git-over-HTTP or the Repository Files API. |
|
||||
| `write_repository` | Grants read-write access to repositories on private projects using Git-over-HTTP (not using the API). |
|
||||
| `read_registry` | Grants read-only access to container registry images on private projects. |
|
||||
| `write_registry` | Grants read-only access to container registry images on private projects. |
|
||||
| `sudo` | Grants permission to perform API actions as any user in the system, when authenticated as an admin user. |
|
||||
| `openid` | Grants permission to authenticate with GitLab using [OpenID Connect](openid_connect_provider.md). Also gives read-only access to the user's profile and group memberships. |
|
||||
| `profile` | Grants read-only access to the user's profile data using [OpenID Connect](openid_connect_provider.md). |
|
||||
| `email` | Grants read-only access to the user's primary email address using [OpenID Connect](openid_connect_provider.md). |
|
||||
|
||||
At any time you can revoke any access by just clicking **Revoke**.
|
||||
|
|
|
@ -12,12 +12,13 @@ module API
|
|||
end
|
||||
params do
|
||||
requires :id, type: String, desc: 'The suggestion ID'
|
||||
optional :commit_message, type: String, desc: "A custom commit message to use instead of the default generated message or the project's default message"
|
||||
end
|
||||
put ':id/apply' do
|
||||
suggestion = Suggestion.find_by_id(params[:id])
|
||||
|
||||
if suggestion
|
||||
apply_suggestions(suggestion, current_user)
|
||||
apply_suggestions(suggestion, current_user, params[:commit_message])
|
||||
else
|
||||
render_api_error!(_('Suggestion is not applicable as the suggestion was not found.'), :not_found)
|
||||
end
|
||||
|
@ -28,6 +29,7 @@ module API
|
|||
end
|
||||
params do
|
||||
requires :ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: "An array of suggestion ID's"
|
||||
optional :commit_message, type: String, desc: "A custom commit message to use instead of the default generated message or the project's default message"
|
||||
end
|
||||
put 'batch_apply' do
|
||||
ids = params[:ids]
|
||||
|
@ -35,7 +37,7 @@ module API
|
|||
suggestions = Suggestion.id_in(ids)
|
||||
|
||||
if suggestions.size == ids.length
|
||||
apply_suggestions(suggestions, current_user)
|
||||
apply_suggestions(suggestions, current_user, params[:commit_message])
|
||||
else
|
||||
render_api_error!(_('Suggestions are not applicable as one or more suggestions were not found.'), :not_found)
|
||||
end
|
||||
|
@ -43,10 +45,10 @@ module API
|
|||
end
|
||||
|
||||
helpers do
|
||||
def apply_suggestions(suggestions, current_user)
|
||||
def apply_suggestions(suggestions, current_user, message)
|
||||
authorize_suggestions(*suggestions)
|
||||
|
||||
result = ::Suggestions::ApplyService.new(current_user, *suggestions).execute
|
||||
result = ::Suggestions::ApplyService.new(current_user, *suggestions, message: message).execute
|
||||
|
||||
if result[:status] == :success
|
||||
present suggestions, with: Entities::Suggestion, current_user: current_user
|
||||
|
|
|
@ -47,3 +47,5 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Gitlab::AlertManagement::Payload.prepend_if_ee('EE::Gitlab::AlertManagement::Payload')
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
# A class used for committing a release's changelog to a Git repository.
|
||||
class Committer
|
||||
CommitError = Class.new(StandardError)
|
||||
|
||||
def initialize(project, user)
|
||||
@project = project
|
||||
@user = user
|
||||
end
|
||||
|
||||
# Commits a release's changelog to a file on a branch.
|
||||
#
|
||||
# The `release` argument is a `Gitlab::Changelog::Release` for which to
|
||||
# update the changelog.
|
||||
#
|
||||
# The `file` argument specifies the path to commit the changes to.
|
||||
#
|
||||
# The `branch` argument specifies the branch to commit the changes on.
|
||||
#
|
||||
# The `message` argument specifies the commit message to use.
|
||||
def commit(release:, file:, branch:, message:)
|
||||
# When retrying, we need to reprocess the existing changelog from
|
||||
# scratch, otherwise we may end up throwing away changes. As such, all
|
||||
# the logic is contained within the retry block.
|
||||
Retriable.retriable(on: CommitError) do
|
||||
commit = @project.commit(branch)
|
||||
content = blob_content(file, commit)
|
||||
|
||||
# If the release has already been added (e.g. concurrently by another
|
||||
# API call), we don't want to add it again.
|
||||
break if content&.match?(release.header_start_pattern)
|
||||
|
||||
service = Files::MultiService.new(
|
||||
@project,
|
||||
@user,
|
||||
commit_message: message,
|
||||
branch_name: branch,
|
||||
start_branch: branch,
|
||||
actions: [
|
||||
{
|
||||
action: content ? 'update' : 'create',
|
||||
content: Generator.new(content.to_s).add(release),
|
||||
file_path: file,
|
||||
last_commit_id: commit&.sha
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = service.execute
|
||||
|
||||
raise CommitError.new(result[:message]) if result[:status] != :success
|
||||
end
|
||||
end
|
||||
|
||||
def blob_content(file, commit = nil)
|
||||
return unless commit
|
||||
|
||||
@project.repository.blob_at(commit.sha, file)&.data
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,74 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
# Configuration settings used when generating changelogs.
|
||||
class Config
|
||||
ConfigError = Class.new(StandardError)
|
||||
|
||||
# When rendering changelog entries, authors are not included.
|
||||
AUTHORS_NONE = 'none'
|
||||
|
||||
# The path to the configuration file as stored in the project's Git
|
||||
# repository.
|
||||
FILE_PATH = '.gitlab/changelog_config.yml'
|
||||
|
||||
# The default date format to use for formatting release dates.
|
||||
DEFAULT_DATE_FORMAT = '%Y-%m-%d'
|
||||
|
||||
# The default template to use for generating release sections.
|
||||
DEFAULT_TEMPLATE = File.read(File.join(__dir__, 'template.tpl'))
|
||||
|
||||
attr_accessor :date_format, :categories, :template
|
||||
|
||||
def self.from_git(project)
|
||||
if (yaml = project.repository.changelog_config)
|
||||
from_hash(project, YAML.safe_load(yaml))
|
||||
else
|
||||
new(project)
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_hash(project, hash)
|
||||
config = new(project)
|
||||
|
||||
if (date = hash['date_format'])
|
||||
config.date_format = date
|
||||
end
|
||||
|
||||
if (template = hash['template'])
|
||||
config.template = Template::Compiler.new.compile(template)
|
||||
end
|
||||
|
||||
if (categories = hash['categories'])
|
||||
if categories.is_a?(Hash)
|
||||
config.categories = categories
|
||||
else
|
||||
raise ConfigError, 'The "categories" configuration key must be a Hash'
|
||||
end
|
||||
end
|
||||
|
||||
config
|
||||
end
|
||||
|
||||
def initialize(project)
|
||||
@project = project
|
||||
@date_format = DEFAULT_DATE_FORMAT
|
||||
@template = Template::Compiler.new.compile(DEFAULT_TEMPLATE)
|
||||
@categories = {}
|
||||
end
|
||||
|
||||
def contributor?(user)
|
||||
@project.team.contributor?(user)
|
||||
end
|
||||
|
||||
def category(name)
|
||||
@categories[name] || name
|
||||
end
|
||||
|
||||
def format_date(date)
|
||||
date.strftime(@date_format)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
# Parsing and generating of Markdown changelogs.
|
||||
class Generator
|
||||
# The regex used to parse a release header.
|
||||
RELEASE_REGEX =
|
||||
/^##\s+(?<version>#{Gitlab::Regex.unbounded_semver_regex})/.freeze
|
||||
|
||||
# The `input` argument must be a `String` containing the existing
|
||||
# changelog Markdown. If no changelog exists, this should be an empty
|
||||
# `String`.
|
||||
def initialize(input = '')
|
||||
@lines = input.lines
|
||||
@locations = {}
|
||||
|
||||
@lines.each_with_index do |line, index|
|
||||
matches = line.match(RELEASE_REGEX)
|
||||
|
||||
next if !matches || !matches[:version]
|
||||
|
||||
@locations[matches[:version]] = index
|
||||
end
|
||||
end
|
||||
|
||||
# Generates the Markdown for the given release and returns the new
|
||||
# changelog Markdown content.
|
||||
#
|
||||
# The `release` argument must be an instance of
|
||||
# `Gitlab::Changelog::Release`.
|
||||
def add(release)
|
||||
versions = [release.version, *@locations.keys]
|
||||
|
||||
VersionSorter.rsort!(versions)
|
||||
|
||||
new_index = versions.index(release.version)
|
||||
new_lines = @lines.dup
|
||||
markdown = release.to_markdown
|
||||
|
||||
if (insert_after = versions[new_index + 1])
|
||||
line_index = @locations[insert_after]
|
||||
|
||||
new_lines.insert(line_index, markdown)
|
||||
else
|
||||
# When adding to the end of the changelog, the previous section only
|
||||
# has a single newline, resulting in the release section title
|
||||
# following it immediately. When this is the case, we insert an extra
|
||||
# empty line to keep the changelog readable in its raw form.
|
||||
new_lines.push("\n") if versions.length > 1
|
||||
new_lines.push(markdown.rstrip)
|
||||
new_lines.push("\n")
|
||||
end
|
||||
|
||||
new_lines.join
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,94 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
# A release to add to a changelog.
|
||||
class Release
|
||||
attr_reader :version
|
||||
|
||||
def initialize(version:, date:, config:)
|
||||
@version = version
|
||||
@date = date
|
||||
@config = config
|
||||
@entries = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
# This ensures that entries are presented in the same order as the
|
||||
# categories Hash in the user's configuration.
|
||||
@config.categories.values.each do |category|
|
||||
@entries[category] = []
|
||||
end
|
||||
end
|
||||
|
||||
def add_entry(
|
||||
title:,
|
||||
commit:,
|
||||
category:,
|
||||
author: nil,
|
||||
merge_request: nil
|
||||
)
|
||||
# When changing these fields, keep in mind that this needs to be
|
||||
# backwards compatible. For example, you can't just remove a field as
|
||||
# this will break the changelog generation process for existing users.
|
||||
entry = {
|
||||
'title' => title,
|
||||
'commit' => {
|
||||
'reference' => commit.to_reference(full: true),
|
||||
'trailers' => commit.trailers
|
||||
}
|
||||
}
|
||||
|
||||
if author
|
||||
entry['author'] = {
|
||||
'reference' => author.to_reference(full: true),
|
||||
'contributor' => @config.contributor?(author)
|
||||
}
|
||||
end
|
||||
|
||||
if merge_request
|
||||
entry['merge_request'] = {
|
||||
'reference' => merge_request.to_reference(full: true)
|
||||
}
|
||||
end
|
||||
|
||||
@entries[@config.category(category)] << entry
|
||||
end
|
||||
|
||||
def to_markdown
|
||||
# While not critical, we would like release sections to be separated by
|
||||
# an empty line in the changelog; ensuring it's readable even in its
|
||||
# raw form.
|
||||
#
|
||||
# Since it can be a bit tricky to get this right using Liquid, we
|
||||
# enforce an empty line separator ourselves.
|
||||
markdown =
|
||||
@config.template.render('categories' => entries_for_template).strip
|
||||
|
||||
# The release header can't be changed using the Liquid template, as we
|
||||
# need this to be in a known format. Without this restriction, we won't
|
||||
# know where to insert a new release section in an existing changelog.
|
||||
"## #{@version} (#{release_date})\n\n#{markdown}\n\n"
|
||||
end
|
||||
|
||||
def header_start_pattern
|
||||
/^##\s*#{Regexp.escape(@version)}/
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def release_date
|
||||
@config.format_date(@date)
|
||||
end
|
||||
|
||||
def entries_for_template
|
||||
@entries.map do |category, entries|
|
||||
{
|
||||
'title' => category,
|
||||
'count' => entries.length,
|
||||
'single_change' => entries.length == 1,
|
||||
'entries' => entries
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
{% if categories %}
|
||||
{% each categories %}
|
||||
### {{ title }} ({% if single_change %}1 change{% else %}{{ count }} changes{% end %})
|
||||
|
||||
{% each entries %}
|
||||
- [{{ title }}]({{ commit.reference }})\
|
||||
{% if author.contributor %} by {{ author.reference }}{% end %}\
|
||||
{% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %}
|
||||
{% end %}
|
||||
|
||||
{% end %}
|
||||
{% else %}
|
||||
No changes.
|
||||
{% end %}
|
|
@ -0,0 +1,146 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
module Template
|
||||
# Compiler is used for turning a minimal user templating language into an
|
||||
# ERB template, without giving the user access to run arbitrary code.
|
||||
#
|
||||
# The template syntax is deliberately made as minimal as possible, and
|
||||
# only supports the following:
|
||||
#
|
||||
# * Printing a value
|
||||
# * Iterating over collections
|
||||
# * if/else
|
||||
#
|
||||
# The syntax looks as follows:
|
||||
#
|
||||
# {% each users %}
|
||||
#
|
||||
# Name: {{user}}
|
||||
# Likes cats: {% if likes_cats %}yes{% else %}no{% end %}
|
||||
#
|
||||
# {% end %}
|
||||
#
|
||||
# Newlines can be escaped by ending a line with a backslash. So this:
|
||||
#
|
||||
# foo \
|
||||
# bar
|
||||
#
|
||||
# Is the same as this:
|
||||
#
|
||||
# foo bar
|
||||
#
|
||||
# Templates are compiled into ERB templates, while taking care to make
|
||||
# sure the user can't run arbitrary code. By using ERB we can let it do
|
||||
# the heavy lifting of rendering data; all we need to provide is a
|
||||
# translation layer.
|
||||
#
|
||||
# # Security
|
||||
#
|
||||
# The template syntax this compiler exposes is safe to be used by
|
||||
# untrusted users. Not only are they unable to run arbitrary code, the
|
||||
# compiler also enforces a limit on the integer sizes and the number of
|
||||
# nested loops. ERB tags added by the user are also disabled.
|
||||
class Compiler
|
||||
# A pattern to match a single integer, with an upper size limit.
|
||||
#
|
||||
# We enforce a limit of 10 digits (= a 32 bits integer) so users can't
|
||||
# trigger the allocation of infinitely large bignums, or trigger
|
||||
# RangeError errors when using such integers to access an array value.
|
||||
INTEGER = /^\d{1,10}$/.freeze
|
||||
|
||||
# The name/path of a variable, such as `user.address.city`.
|
||||
#
|
||||
# It's important that this regular expression _doesn't_ allow for
|
||||
# anything but letters, numbers, and underscores, otherwise a user may
|
||||
# use those to "escape" our template and run arbirtary Ruby code. For
|
||||
# example, take this variable:
|
||||
#
|
||||
# {{') ::Kernel.exit #'}}
|
||||
#
|
||||
# This would then be compiled into:
|
||||
#
|
||||
# <%= read(variables, '') ::Kernel.exit #'') %>
|
||||
#
|
||||
# Restricting the allowed characters makes this impossible.
|
||||
VAR_NAME = /([\w\.]+)/.freeze
|
||||
|
||||
# A variable tag, such as `{{username}}`.
|
||||
VAR = /{{ \s* #{VAR_NAME} \s* }}/x.freeze
|
||||
|
||||
# The opening tag for a statement.
|
||||
STM_START = /{% \s*/x.freeze
|
||||
|
||||
# The closing tag for a statement.
|
||||
STM_END = /\s* %}/x.freeze
|
||||
|
||||
# A regular `end` closing tag.
|
||||
NORMAL_END = /#{STM_START} end #{STM_END}/x.freeze
|
||||
|
||||
# An `end` closing tag on its own line, without any non-whitespace
|
||||
# preceding or following it.
|
||||
#
|
||||
# These tags need some special care to make it easier to control
|
||||
# whitespace.
|
||||
LONELY_END = /^\s*#{NORMAL_END}\s$/x.freeze
|
||||
|
||||
# An `else` tag.
|
||||
ELSE = /#{STM_START} else #{STM_END}/x.freeze
|
||||
|
||||
# The start of an `each` tag.
|
||||
EACH = /#{STM_START} each \s+ #{VAR_NAME} #{STM_END}/x.freeze
|
||||
|
||||
# The start of an `if` tag.
|
||||
IF = /#{STM_START} if \s+ #{VAR_NAME} #{STM_END}/x.freeze
|
||||
|
||||
# The pattern to use for escaping newlines.
|
||||
ESCAPED_NEWLINE = /\\\n$/.freeze
|
||||
|
||||
# The start tag for ERB tags. These tags will be escaped, preventing
|
||||
# users FROM USING erb DIRECTLY.
|
||||
ERB_START_TAG = '<%'
|
||||
|
||||
def compile(template)
|
||||
transformed_lines = ['<% it = variables %>']
|
||||
|
||||
template.each_line { |line| transformed_lines << transform(line) }
|
||||
Template.new(transformed_lines.join)
|
||||
end
|
||||
|
||||
def transform(line)
|
||||
line.gsub!(ESCAPED_NEWLINE, '')
|
||||
line.gsub!(ERB_START_TAG, '<%%')
|
||||
|
||||
# This replacement ensures that "end" blocks on their own lines
|
||||
# don't add extra newlines. Using an ERB -%> tag sadly swallows too
|
||||
# many newlines.
|
||||
line.gsub!(LONELY_END, '<% end %>')
|
||||
line.gsub!(NORMAL_END, '<% end %>')
|
||||
line.gsub!(ELSE, '<% else -%>')
|
||||
|
||||
line.gsub!(EACH) do
|
||||
# No, `it; variables` isn't a syntax error. Using `;` marks
|
||||
# `variables` as block-local, making it possible to re-assign it
|
||||
# without affecting outer definitions of this variable. We use
|
||||
# this to scope template variables to the right input Hash.
|
||||
"<% each(#{read_path(Regexp.last_match(1))}) do |it; variables| -%><% variables = it -%>"
|
||||
end
|
||||
|
||||
line.gsub!(IF) { "<% if truthy?(#{read_path(Regexp.last_match(1))}) -%>" }
|
||||
line.gsub!(VAR) { "<%= #{read_path(Regexp.last_match(1))} %>" }
|
||||
line
|
||||
end
|
||||
|
||||
def read_path(path)
|
||||
return path if path == 'it'
|
||||
|
||||
args = path.split('.')
|
||||
args.map! { |arg| arg.match?(INTEGER) ? "#{arg}" : "'#{arg}'" }
|
||||
|
||||
"read(variables, #{args.join(', ')})"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
module Template
|
||||
# Context is used to provide a binding/context to ERB templates used for
|
||||
# rendering changelogs.
|
||||
#
|
||||
# This class extends BasicObject so that we only expose the bare minimum
|
||||
# needed to render the ERB template.
|
||||
class Context < BasicObject
|
||||
MAX_NESTED_LOOPS = 4
|
||||
|
||||
def initialize(variables)
|
||||
@variables = variables
|
||||
@loop_nesting = 0
|
||||
end
|
||||
|
||||
def get_binding
|
||||
::Kernel.binding
|
||||
end
|
||||
|
||||
def each(value, &block)
|
||||
max = MAX_NESTED_LOOPS
|
||||
|
||||
if @loop_nesting == max
|
||||
::Kernel.raise(
|
||||
::Template::TemplateError.new("You can only nest up to #{max} loops")
|
||||
)
|
||||
end
|
||||
|
||||
@loop_nesting += 1
|
||||
result = value.each(&block) if value.respond_to?(:each)
|
||||
@loop_nesting -= 1
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# rubocop: disable Style/TrivialAccessors
|
||||
def variables
|
||||
@variables
|
||||
end
|
||||
# rubocop: enable Style/TrivialAccessors
|
||||
|
||||
def read(source, *steps)
|
||||
current = source
|
||||
|
||||
steps.each do |step|
|
||||
case current
|
||||
when ::Hash
|
||||
current = current[step]
|
||||
when ::Array
|
||||
return '' unless step.is_a?(::Integer)
|
||||
|
||||
current = current[step]
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
current
|
||||
end
|
||||
|
||||
def truthy?(value)
|
||||
value.respond_to?(:any?) ? value.any? : !!value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Changelog
|
||||
module Template
|
||||
# A wrapper around an ERB template user for rendering changelogs.
|
||||
class Template
|
||||
TemplateError = Class.new(StandardError)
|
||||
|
||||
def initialize(erb)
|
||||
# Don't change the trim mode, as this may require changes to the
|
||||
# regular expressions used to turn the template syntax into ERB
|
||||
# tags.
|
||||
@erb = ERB.new(erb, trim_mode: '-')
|
||||
end
|
||||
|
||||
def render(data)
|
||||
context = Context.new(data).get_binding
|
||||
|
||||
# ERB produces a SyntaxError when processing templates, as it
|
||||
# internally uses eval() for this.
|
||||
@erb.result(context)
|
||||
rescue SyntaxError
|
||||
raise TemplateError.new("The template's syntax is invalid")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,14 +6,15 @@ module Gitlab
|
|||
DEFAULT_SUGGESTION_COMMIT_MESSAGE =
|
||||
'Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)'
|
||||
|
||||
def initialize(user, suggestion_set)
|
||||
def initialize(user, suggestion_set, custom_message = nil)
|
||||
@user = user
|
||||
@suggestion_set = suggestion_set
|
||||
@custom_message = custom_message
|
||||
end
|
||||
|
||||
def message
|
||||
project = suggestion_set.project
|
||||
user_defined_message = project.suggestion_commit_message.presence
|
||||
user_defined_message = @custom_message.presence || project.suggestion_commit_message.presence
|
||||
message = user_defined_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE
|
||||
|
||||
Gitlab::StringPlaceholderReplacer
|
||||
|
|
|
@ -1927,9 +1927,6 @@ msgstr ""
|
|||
msgid "AdminArea|Features"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Groups: %{number_of_groups}"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Guest"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1963,7 +1960,7 @@ msgstr ""
|
|||
msgid "AdminArea|Owner"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Projects: %{number_of_projects}"
|
||||
msgid "AdminArea|Projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Reporter"
|
||||
|
@ -1987,6 +1984,9 @@ msgstr ""
|
|||
msgid "AdminArea|User cap"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Users statistics"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1996,7 +1996,13 @@ msgstr ""
|
|||
msgid "AdminArea|Users without a Group and Project"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|Users: %{number_of_users}"
|
||||
msgid "AdminArea|View latest groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|View latest projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|View latest users"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
|
||||
|
@ -4445,7 +4451,7 @@ msgstr ""
|
|||
msgid "Bi-weekly code coverage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Billable Users:"
|
||||
msgid "Billable Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Billing"
|
||||
|
@ -4466,7 +4472,7 @@ msgstr ""
|
|||
msgid "BillingPlans|If you would like to downgrade your plan please contact %{support_link_start}Customer Support%{support_link_end}."
|
||||
msgstr ""
|
||||
|
||||
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}, or start a free 30-day trial of GitLab.com Gold."
|
||||
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}, or start a free 30-day trial of GitLab.com Ultimate."
|
||||
msgstr ""
|
||||
|
||||
msgid "BillingPlans|Learn more about each plan by visiting our %{pricing_page_link}."
|
||||
|
@ -8552,7 +8558,7 @@ msgstr ""
|
|||
msgid "CurrentUser|Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "CurrentUser|Start a Gold trial"
|
||||
msgid "CurrentUser|Start an Ultimate trial"
|
||||
msgstr ""
|
||||
|
||||
msgid "CurrentUser|Upgrade"
|
||||
|
@ -15281,6 +15287,9 @@ msgstr ""
|
|||
msgid "Instance administrators group already exists"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instance overview"
|
||||
msgstr ""
|
||||
|
||||
msgid "InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again."
|
||||
msgstr ""
|
||||
|
||||
|
@ -16678,7 +16687,7 @@ msgstr ""
|
|||
msgid "Learn GitLab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Learn GitLab - Gold trial"
|
||||
msgid "Learn GitLab - Ultimate trial"
|
||||
msgstr ""
|
||||
|
||||
msgid "Learn how to %{link_start}contribute to the built-in templates%{link_end}"
|
||||
|
@ -17460,7 +17469,7 @@ msgstr ""
|
|||
msgid "Maximum PyPI package file size in bytes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Maximum Users:"
|
||||
msgid "Maximum Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Maximum allowable lifetime for personal access token (days)"
|
||||
|
@ -26968,7 +26977,7 @@ msgstr ""
|
|||
msgid "Start a %{new_merge_request} with these changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Start a Free Gold Trial"
|
||||
msgid "Start a Free Ultimate Trial"
|
||||
msgstr ""
|
||||
|
||||
msgid "Start a new discussion..."
|
||||
|
@ -27010,7 +27019,7 @@ msgstr ""
|
|||
msgid "Start thread & reopen %{noteable_name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Start your Free Gold Trial"
|
||||
msgid "Start your Free Ultimate Trial"
|
||||
msgstr ""
|
||||
|
||||
msgid "Start your free trial"
|
||||
|
@ -30077,7 +30086,7 @@ msgid_plural "Trials|%{plan} Trial %{en_dash} %{num} days left"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "Trials|Create a new group to start your GitLab Gold trial."
|
||||
msgid "Trials|Create a new group to start your GitLab Ultimate trial."
|
||||
msgstr ""
|
||||
|
||||
msgid "Trials|Go back to GitLab"
|
||||
|
@ -30089,7 +30098,7 @@ msgstr ""
|
|||
msgid "Trials|Skip Trial (Continue with Free Account)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trials|You can always resume this process by selecting your avatar and choosing 'Start a Gold trial'"
|
||||
msgid "Trials|You can always resume this process by selecting your avatar and choosing 'Start an Ultimate trial'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trials|You can apply your trial to a new group or an existing group."
|
||||
|
@ -30116,7 +30125,7 @@ msgstr ""
|
|||
msgid "Trial|Dismiss"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trial|GitLab Gold trial (optional)"
|
||||
msgid "Trial|GitLab Ultimate trial (optional)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trial|How many employees will use Gitlab?"
|
||||
|
@ -30131,7 +30140,7 @@ msgstr ""
|
|||
msgid "Trial|Telephone number"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trial|Upgrade to Gold to keep using GitLab with advanced features."
|
||||
msgid "Trial|Upgrade to Ultimate to keep using GitLab with advanced features."
|
||||
msgstr ""
|
||||
|
||||
msgid "Trial|We will activate your trial on your group after you complete this step. After 30 days, you can:"
|
||||
|
@ -31166,13 +31175,10 @@ msgstr ""
|
|||
msgid "Users in License"
|
||||
msgstr ""
|
||||
|
||||
msgid "Users in License:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Users or groups set as approvers in the project's or merge request's settings."
|
||||
msgstr ""
|
||||
|
||||
msgid "Users over License:"
|
||||
msgid "Users over License"
|
||||
msgstr ""
|
||||
|
||||
msgid "Users requesting access to"
|
||||
|
@ -32900,7 +32906,7 @@ msgstr ""
|
|||
msgid "Your GitLab group"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your Gitlab Gold trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial."
|
||||
msgid "Your Gitlab Ultimate trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your Groups"
|
||||
|
|
|
@ -306,66 +306,6 @@ RSpec.describe GroupsController, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tracking group creation for onboarding issues experiment' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
subject(:create_namespace) { post :create, params: { group: { name: 'new_group', path: 'new_group' } } }
|
||||
|
||||
context 'experiment disabled' do
|
||||
before do
|
||||
stub_experiment(onboarding_issues: false)
|
||||
end
|
||||
|
||||
it 'does not track anything', :snowplow do
|
||||
create_namespace
|
||||
|
||||
expect_no_snowplow_event
|
||||
end
|
||||
end
|
||||
|
||||
context 'experiment enabled' do
|
||||
before do
|
||||
stub_experiment(onboarding_issues: true)
|
||||
end
|
||||
|
||||
context 'and the user is part of the control group' do
|
||||
before do
|
||||
stub_experiment_for_subject(onboarding_issues: false)
|
||||
end
|
||||
|
||||
it 'tracks the event with the "created_namespace" action with the "control_group" property', :snowplow do
|
||||
create_namespace
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'Growth::Conversion::Experiment::OnboardingIssues',
|
||||
action: 'created_namespace',
|
||||
label: anything,
|
||||
property: 'control_group'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'and the user is part of the experimental group' do
|
||||
before do
|
||||
stub_experiment_for_subject(onboarding_issues: true)
|
||||
end
|
||||
|
||||
it 'tracks the event with the "created_namespace" action with the "experimental_group" property', :snowplow do
|
||||
create_namespace
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'Growth::Conversion::Experiment::OnboardingIssues',
|
||||
action: 'created_namespace',
|
||||
label: anything,
|
||||
property: 'experimental_group'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Changelog::Committer do
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:user) { project.creator }
|
||||
let(:committer) { described_class.new(project, user) }
|
||||
let(:config) { Gitlab::Changelog::Config.new(project) }
|
||||
|
||||
describe '#commit' do
|
||||
context "when the release isn't in the changelog" do
|
||||
it 'commits the changes' do
|
||||
release = Gitlab::Changelog::Release
|
||||
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
|
||||
|
||||
committer.commit(
|
||||
release: release,
|
||||
file: 'CHANGELOG.md',
|
||||
branch: 'master',
|
||||
message: 'Test commit'
|
||||
)
|
||||
|
||||
content = project.repository.blob_at('master', 'CHANGELOG.md').data
|
||||
|
||||
expect(content).to eq(<<~MARKDOWN)
|
||||
## 1.0.0 (2020-01-01)
|
||||
|
||||
No changes.
|
||||
MARKDOWN
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the release is already in the changelog' do
|
||||
it "doesn't commit the changes" do
|
||||
release = Gitlab::Changelog::Release
|
||||
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
|
||||
|
||||
2.times do
|
||||
committer.commit(
|
||||
release: release,
|
||||
file: 'CHANGELOG.md',
|
||||
branch: 'master',
|
||||
message: 'Test commit'
|
||||
)
|
||||
end
|
||||
|
||||
content = project.repository.blob_at('master', 'CHANGELOG.md').data
|
||||
|
||||
expect(content).to eq(<<~MARKDOWN)
|
||||
## 1.0.0 (2020-01-01)
|
||||
|
||||
No changes.
|
||||
MARKDOWN
|
||||
end
|
||||
end
|
||||
|
||||
context 'when committing the changes fails' do
|
||||
it 'retries the operation' do
|
||||
release = Gitlab::Changelog::Release
|
||||
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
|
||||
|
||||
service = instance_spy(Files::MultiService)
|
||||
errored = false
|
||||
|
||||
allow(Files::MultiService)
|
||||
.to receive(:new)
|
||||
.and_return(service)
|
||||
|
||||
allow(service).to receive(:execute) do
|
||||
if errored
|
||||
{ status: :success }
|
||||
else
|
||||
errored = true
|
||||
{ status: :error }
|
||||
end
|
||||
end
|
||||
|
||||
expect do
|
||||
committer.commit(
|
||||
release: release,
|
||||
file: 'CHANGELOG.md',
|
||||
branch: 'master',
|
||||
message: 'Test commit'
|
||||
)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,96 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Changelog::Config do
|
||||
let(:project) { build_stubbed(:project) }
|
||||
|
||||
describe '.from_git' do
|
||||
it 'retrieves the configuration from Git' do
|
||||
allow(project.repository)
|
||||
.to receive(:changelog_config)
|
||||
.and_return("---\ndate_format: '%Y'")
|
||||
|
||||
expect(described_class)
|
||||
.to receive(:from_hash)
|
||||
.with(project, 'date_format' => '%Y')
|
||||
|
||||
described_class.from_git(project)
|
||||
end
|
||||
|
||||
it 'returns the default configuration when no YAML file exists in Git' do
|
||||
allow(project.repository)
|
||||
.to receive(:changelog_config)
|
||||
.and_return(nil)
|
||||
|
||||
expect(described_class)
|
||||
.to receive(:new)
|
||||
.with(project)
|
||||
|
||||
described_class.from_git(project)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.from_hash' do
|
||||
it 'sets the configuration according to a Hash' do
|
||||
config = described_class.from_hash(
|
||||
project,
|
||||
'date_format' => 'foo',
|
||||
'template' => 'bar',
|
||||
'categories' => { 'foo' => 'bar' }
|
||||
)
|
||||
|
||||
expect(config.date_format).to eq('foo')
|
||||
expect(config.template).to be_instance_of(Gitlab::Changelog::Template::Template)
|
||||
expect(config.categories).to eq({ 'foo' => 'bar' })
|
||||
end
|
||||
|
||||
it 'raises ConfigError when the categories are not a Hash' do
|
||||
expect { described_class.from_hash(project, 'categories' => 10) }
|
||||
.to raise_error(described_class::ConfigError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#contributor?' do
|
||||
it 'returns true if a user is a contributor' do
|
||||
user = build_stubbed(:author)
|
||||
|
||||
allow(project.team).to receive(:contributor?).with(user).and_return(true)
|
||||
|
||||
expect(described_class.new(project).contributor?(user)).to eq(true)
|
||||
end
|
||||
|
||||
it "returns true if a user isn't a contributor" do
|
||||
user = build_stubbed(:author)
|
||||
|
||||
allow(project.team).to receive(:contributor?).with(user).and_return(false)
|
||||
|
||||
expect(described_class.new(project).contributor?(user)).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#category' do
|
||||
it 'returns the name of a category' do
|
||||
config = described_class.new(project)
|
||||
|
||||
config.categories['foo'] = 'Foo'
|
||||
|
||||
expect(config.category('foo')).to eq('Foo')
|
||||
end
|
||||
|
||||
it 'returns the raw category name when no alternative name is configured' do
|
||||
config = described_class.new(project)
|
||||
|
||||
expect(config.category('bla')).to eq('bla')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#format_date' do
|
||||
it 'formats a date according to the configured date format' do
|
||||
config = described_class.new(project)
|
||||
time = Time.utc(2021, 1, 5)
|
||||
|
||||
expect(config.format_date(time)).to eq('2021-01-05')
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,164 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Changelog::Generator do
|
||||
describe '#add' do
|
||||
let(:project) { build_stubbed(:project) }
|
||||
let(:author) { build_stubbed(:user) }
|
||||
let(:commit) { build_stubbed(:commit) }
|
||||
let(:config) { Gitlab::Changelog::Config.new(project) }
|
||||
|
||||
it 'generates the Markdown for the first release' do
|
||||
release = Gitlab::Changelog::Release.new(
|
||||
version: '1.0.0',
|
||||
date: Time.utc(2021, 1, 5),
|
||||
config: config
|
||||
)
|
||||
|
||||
release.add_entry(
|
||||
title: 'This is a new change',
|
||||
commit: commit,
|
||||
category: 'added',
|
||||
author: author
|
||||
)
|
||||
|
||||
gen = described_class.new('')
|
||||
|
||||
expect(gen.add(release)).to eq(<<~MARKDOWN)
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
### added (1 change)
|
||||
|
||||
- [This is a new change](#{commit.to_reference(full: true)})
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'generates the Markdown for a newer release' do
|
||||
release = Gitlab::Changelog::Release.new(
|
||||
version: '2.0.0',
|
||||
date: Time.utc(2021, 1, 5),
|
||||
config: config
|
||||
)
|
||||
|
||||
release.add_entry(
|
||||
title: 'This is a new change',
|
||||
commit: commit,
|
||||
category: 'added',
|
||||
author: author
|
||||
)
|
||||
|
||||
gen = described_class.new(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
MARKDOWN
|
||||
|
||||
expect(gen.add(release)).to eq(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 2.0.0 (2021-01-05)
|
||||
|
||||
### added (1 change)
|
||||
|
||||
- [This is a new change](#{commit.to_reference(full: true)})
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'generates the Markdown for a patch release' do
|
||||
release = Gitlab::Changelog::Release.new(
|
||||
version: '1.1.0',
|
||||
date: Time.utc(2021, 1, 5),
|
||||
config: config
|
||||
)
|
||||
|
||||
release.add_entry(
|
||||
title: 'This is a new change',
|
||||
commit: commit,
|
||||
category: 'added',
|
||||
author: author
|
||||
)
|
||||
|
||||
gen = described_class.new(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
This is another release.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
MARKDOWN
|
||||
|
||||
expect(gen.add(release)).to eq(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
This is another release.
|
||||
|
||||
## 1.1.0 (2021-01-05)
|
||||
|
||||
### added (1 change)
|
||||
|
||||
- [This is a new change](#{commit.to_reference(full: true)})
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'generates the Markdown for an old release' do
|
||||
release = Gitlab::Changelog::Release.new(
|
||||
version: '0.5.0',
|
||||
date: Time.utc(2021, 1, 5),
|
||||
config: config
|
||||
)
|
||||
|
||||
release.add_entry(
|
||||
title: 'This is a new change',
|
||||
commit: commit,
|
||||
category: 'added',
|
||||
author: author
|
||||
)
|
||||
|
||||
gen = described_class.new(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
This is another release.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
MARKDOWN
|
||||
|
||||
expect(gen.add(release)).to eq(<<~MARKDOWN)
|
||||
This is a changelog file.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
This is another release.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
This is the changelog for version 1.0.0.
|
||||
|
||||
## 0.5.0 (2021-01-05)
|
||||
|
||||
### added (1 change)
|
||||
|
||||
- [This is a new change](#{commit.to_reference(full: true)})
|
||||
MARKDOWN
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,107 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Changelog::Release do
|
||||
describe '#to_markdown' do
|
||||
let(:config) { Gitlab::Changelog::Config.new(build_stubbed(:project)) }
|
||||
let(:commit) { build_stubbed(:commit) }
|
||||
let(:author) { build_stubbed(:user) }
|
||||
let(:mr) { build_stubbed(:merge_request) }
|
||||
let(:release) do
|
||||
described_class
|
||||
.new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config)
|
||||
end
|
||||
|
||||
context 'when there are no entries' do
|
||||
it 'includes a notice about the lack of entries' do
|
||||
expect(release.to_markdown).to eq(<<~OUT)
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
No changes.
|
||||
|
||||
OUT
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all data is present' do
|
||||
it 'includes all data' do
|
||||
allow(config).to receive(:contributor?).with(author).and_return(true)
|
||||
|
||||
release.add_entry(
|
||||
title: 'Entry title',
|
||||
commit: commit,
|
||||
category: 'fixed',
|
||||
author: author,
|
||||
merge_request: mr
|
||||
)
|
||||
|
||||
expect(release.to_markdown).to eq(<<~OUT)
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
### fixed (1 change)
|
||||
|
||||
- [Entry title](#{commit.to_reference(full: true)}) \
|
||||
by #{author.to_reference(full: true)} \
|
||||
([merge request](#{mr.to_reference(full: true)}))
|
||||
|
||||
OUT
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no merge request is present' do
|
||||
it "doesn't include a merge request link" do
|
||||
allow(config).to receive(:contributor?).with(author).and_return(true)
|
||||
|
||||
release.add_entry(
|
||||
title: 'Entry title',
|
||||
commit: commit,
|
||||
category: 'fixed',
|
||||
author: author
|
||||
)
|
||||
|
||||
expect(release.to_markdown).to eq(<<~OUT)
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
### fixed (1 change)
|
||||
|
||||
- [Entry title](#{commit.to_reference(full: true)}) \
|
||||
by #{author.to_reference(full: true)}
|
||||
|
||||
OUT
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the author is not a contributor' do
|
||||
it "doesn't include the author" do
|
||||
allow(config).to receive(:contributor?).with(author).and_return(false)
|
||||
|
||||
release.add_entry(
|
||||
title: 'Entry title',
|
||||
commit: commit,
|
||||
category: 'fixed',
|
||||
author: author
|
||||
)
|
||||
|
||||
expect(release.to_markdown).to eq(<<~OUT)
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
### fixed (1 change)
|
||||
|
||||
- [Entry title](#{commit.to_reference(full: true)})
|
||||
|
||||
OUT
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#header_start_position' do
|
||||
it 'returns a regular expression for finding the start of a release section' do
|
||||
config = Gitlab::Changelog::Config.new(build_stubbed(:project))
|
||||
release = described_class
|
||||
.new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config)
|
||||
|
||||
expect(release.header_start_pattern).to eq(/^##\s*1\.0\.0/)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,129 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Changelog::Template::Compiler do
|
||||
def compile(template, data = {})
|
||||
Gitlab::Changelog::Template::Compiler.new.compile(template).render(data)
|
||||
end
|
||||
|
||||
describe '#compile' do
|
||||
it 'compiles an empty template' do
|
||||
expect(compile('')).to eq('')
|
||||
end
|
||||
|
||||
it 'compiles a template with an undefined variable' do
|
||||
expect(compile('{{number}}')).to eq('')
|
||||
end
|
||||
|
||||
it 'compiles a template with a defined variable' do
|
||||
expect(compile('{{number}}', 'number' => 42)).to eq('42')
|
||||
end
|
||||
|
||||
it 'compiles a template with the special "it" variable' do
|
||||
expect(compile('{{it}}', 'values' => 10)).to eq({ 'values' => 10 }.to_s)
|
||||
end
|
||||
|
||||
it 'compiles a template containing an if statement' do
|
||||
expect(compile('{% if foo %}yes{% end %}', 'foo' => true)).to eq('yes')
|
||||
end
|
||||
|
||||
it 'compiles a template containing an if/else statement' do
|
||||
expect(compile('{% if foo %}yes{% else %}no{% end %}', 'foo' => false))
|
||||
.to eq('no')
|
||||
end
|
||||
|
||||
it 'compiles a template that iterates over an Array' do
|
||||
expect(compile('{% each numbers %}{{it}}{% end %}', 'numbers' => [1, 2, 3]))
|
||||
.to eq('123')
|
||||
end
|
||||
|
||||
it 'compiles a template that iterates over a Hash' do
|
||||
output = compile(
|
||||
'{% each pairs %}{{0}}={{1}}{% end %}',
|
||||
'pairs' => { 'key' => 'value' }
|
||||
)
|
||||
|
||||
expect(output).to eq('key=value')
|
||||
end
|
||||
|
||||
it 'compiles a template that iterates over a Hash of Arrays' do
|
||||
output = compile(
|
||||
'{% each values %}{{key}}{% end %}',
|
||||
'values' => [{ 'key' => 'value' }]
|
||||
)
|
||||
|
||||
expect(output).to eq('value')
|
||||
end
|
||||
|
||||
it 'compiles a template with a variable path' do
|
||||
output = compile('{{foo.bar}}', 'foo' => { 'bar' => 10 })
|
||||
|
||||
expect(output).to eq('10')
|
||||
end
|
||||
|
||||
it 'compiles a template with a variable path that uses an Array index' do
|
||||
output = compile('{{foo.values.1}}', 'foo' => { 'values' => [10, 20] })
|
||||
|
||||
expect(output).to eq('20')
|
||||
end
|
||||
|
||||
it 'compiles a template with a variable path that uses a Hash and a numeric index' do
|
||||
output = compile('{{foo.1}}', 'foo' => { 'key' => 'value' })
|
||||
|
||||
expect(output).to eq('')
|
||||
end
|
||||
|
||||
it 'compiles a template with a variable path that uses an Array and a String based index' do
|
||||
output = compile('{{foo.numbers.bla}}', 'foo' => { 'numbers' => [10, 20] })
|
||||
|
||||
expect(output).to eq('')
|
||||
end
|
||||
|
||||
it 'ignores ERB tags provided by the user' do
|
||||
input = '<% exit %> <%= exit %> <%= foo -%>'
|
||||
|
||||
expect(compile(input)).to eq(input)
|
||||
end
|
||||
|
||||
it 'removes newlines introduced by end statements on their own lines' do
|
||||
output = compile(<<~TPL, 'foo' => true)
|
||||
{% if foo %}
|
||||
foo
|
||||
{% end %}
|
||||
TPL
|
||||
|
||||
expect(output).to eq("foo\n")
|
||||
end
|
||||
|
||||
it 'supports escaping of trailing newlines' do
|
||||
output = compile(<<~TPL)
|
||||
foo \
|
||||
bar\
|
||||
baz
|
||||
TPL
|
||||
|
||||
expect(output).to eq("foo barbaz\n")
|
||||
end
|
||||
|
||||
# rubocop: disable Lint/InterpolationCheck
|
||||
it 'ignores embedded Ruby expressions' do
|
||||
input = '#{exit}'
|
||||
|
||||
expect(compile(input)).to eq(input)
|
||||
end
|
||||
# rubocop: enable Lint/InterpolationCheck
|
||||
|
||||
it 'ignores ERB tags inside variable tags' do
|
||||
input = '{{<%= exit %>}}'
|
||||
|
||||
expect(compile(input)).to eq(input)
|
||||
end
|
||||
|
||||
it 'ignores malicious code that tries to escape a variable' do
|
||||
input = "{{') ::Kernel.exit # '}}"
|
||||
|
||||
expect(compile(input)).to eq(input)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -72,6 +72,17 @@ RSpec.describe Gitlab::Suggestions::CommitMessage do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when a custom commit message is specified' do
|
||||
let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" }
|
||||
let(:custom_message) { "hello there! i'm a cool custom commit message." }
|
||||
|
||||
it 'shows the custom commit message' do
|
||||
expect(Gitlab::Suggestions::CommitMessage
|
||||
.new(user, suggestion_set, custom_message)
|
||||
.message).to eq(custom_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'is specified and includes all placeholders' do
|
||||
let(:message) do
|
||||
'*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***'
|
||||
|
|
|
@ -5,7 +5,7 @@ require 'spec_helper'
|
|||
RSpec.describe Release do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:project) { create(:project, :public, :repository) }
|
||||
let_it_be(:release) { create(:release, project: project, author: user) }
|
||||
let(:release) { create(:release, project: project, author: user) }
|
||||
|
||||
it { expect(release).to be_valid }
|
||||
|
||||
|
@ -89,6 +89,61 @@ RSpec.describe Release do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#update' do
|
||||
subject { release.update(params) }
|
||||
|
||||
context 'when links do not exist' do
|
||||
context 'when params are specified for creation' do
|
||||
let(:params) do
|
||||
{ links_attributes: [{ name: 'test', url: 'https://www.google.com/' }] }
|
||||
end
|
||||
|
||||
it 'creates a link successfuly' do
|
||||
is_expected.to eq(true)
|
||||
|
||||
expect(release.links.count).to eq(1)
|
||||
expect(release.links.first.name).to eq('test')
|
||||
expect(release.links.first.url).to eq('https://www.google.com/')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a link exists' do
|
||||
let!(:link1) { create(:release_link, release: release, name: 'test1', url: 'https://www.google1.com/') }
|
||||
let!(:link2) { create(:release_link, release: release, name: 'test2', url: 'https://www.google2.com/') }
|
||||
|
||||
before do
|
||||
release.reload
|
||||
end
|
||||
|
||||
context 'when params are specified for update' do
|
||||
let(:params) do
|
||||
{ links_attributes: [{ id: link1.id, name: 'new' }] }
|
||||
end
|
||||
|
||||
it 'updates the link successfully' do
|
||||
is_expected.to eq(true)
|
||||
|
||||
expect(release.links.count).to eq(2)
|
||||
expect(release.links.first.name).to eq('new')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when params are specified for deletion' do
|
||||
let(:params) do
|
||||
{ links_attributes: [{ id: link1.id, _destroy: true }] }
|
||||
end
|
||||
|
||||
it 'removes the link successfuly' do
|
||||
is_expected.to eq(true)
|
||||
|
||||
expect(release.links.count).to eq(1)
|
||||
expect(release.links.first.name).to eq(link2.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sources' do
|
||||
subject { release.sources }
|
||||
|
||||
|
|
|
@ -309,10 +309,7 @@ RSpec.describe 'Creation of a new release' do
|
|||
let(:asset_link_2) { { name: 'My link', url: 'https://example.com/2' } }
|
||||
let(:assets) { { links: [asset_link_1, asset_link_2] } }
|
||||
|
||||
# Right now the raw Postgres error message is sent to the user as the validation message.
|
||||
# We should catch this validation error and return a nicer message:
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/277087
|
||||
it_behaves_like 'errors-as-data with message', 'PG::UniqueViolation'
|
||||
it_behaves_like 'errors-as-data with message', %r{Validation failed: Links have duplicate values \(My link\)}
|
||||
end
|
||||
|
||||
context 'when two release assets share the same URL' do
|
||||
|
@ -320,8 +317,7 @@ RSpec.describe 'Creation of a new release' do
|
|||
let(:asset_link_2) { { name: 'My second link', url: 'https://example.com' } }
|
||||
let(:assets) { { links: [asset_link_1, asset_link_2] } }
|
||||
|
||||
# Same note as above about the ugly error message
|
||||
it_behaves_like 'errors-as-data with message', 'PG::UniqueViolation'
|
||||
it_behaves_like 'errors-as-data with message', %r{Validation failed: Links have duplicate values \(https://example.com\)}
|
||||
end
|
||||
|
||||
context 'when the provided tag name is HEAD' do
|
||||
|
|
|
@ -283,7 +283,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
|
|||
let_it_be(:project) { create(:project, :repository, :private) }
|
||||
let_it_be(:milestone_1) { create(:milestone, project: project) }
|
||||
let_it_be(:milestone_2) { create(:milestone, project: project) }
|
||||
let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
|
||||
let_it_be(:release, reload: true) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
|
||||
let_it_be(:release_link_1) { create(:release_link, release: release) }
|
||||
let_it_be(:release_link_2) { create(:release_link, release: release, filepath: link_filepath) }
|
||||
|
||||
|
@ -324,7 +324,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
|
|||
let_it_be(:project) { create(:project, :repository, :public) }
|
||||
let_it_be(:milestone_1) { create(:milestone, project: project) }
|
||||
let_it_be(:milestone_2) { create(:milestone, project: project) }
|
||||
let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
|
||||
let_it_be(:release, reload: true) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2], released_at: released_at) }
|
||||
let_it_be(:release_link_1) { create(:release_link, release: release) }
|
||||
let_it_be(:release_link_2) { create(:release_link, release: release, filepath: link_filepath) }
|
||||
|
||||
|
|
|
@ -65,6 +65,19 @@ RSpec.describe API::Suggestions do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when a custom commit message is included' do
|
||||
it 'renders an ok response and returns json content' do
|
||||
project.add_maintainer(user)
|
||||
|
||||
message = "cool custom commit message!"
|
||||
|
||||
put api(url, user), params: { commit_message: message }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(project.repository.commit.message).to eq(message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not able to apply patch' do
|
||||
let(:url) { "/suggestions/#{unappliable_suggestion.id}/apply" }
|
||||
|
||||
|
@ -113,9 +126,11 @@ RSpec.describe API::Suggestions do
|
|||
let(:url) { "/suggestions/batch_apply" }
|
||||
|
||||
context 'when successfully applies multiple patches as a batch' do
|
||||
it 'renders an ok response and returns json content' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it 'renders an ok response and returns json content' do
|
||||
put api(url, user), params: { ids: [suggestion.id, suggestion2.id] }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
|
@ -123,6 +138,16 @@ RSpec.describe API::Suggestions do
|
|||
'appliable', 'applied',
|
||||
'from_content', 'to_content'))
|
||||
end
|
||||
|
||||
it 'provides a custom commit message' do
|
||||
message = "cool custom commit message!"
|
||||
|
||||
put api(url, user), params: { ids: [suggestion.id, suggestion2.id],
|
||||
commit_message: message }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(project.repository.commit.message).to eq(message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not able to apply one or more of the patches' do
|
||||
|
|
|
@ -32,8 +32,8 @@ RSpec.describe Suggestions::ApplyService do
|
|||
create(:suggestion, :content_from_repo, suggestion_args)
|
||||
end
|
||||
|
||||
def apply(suggestions)
|
||||
result = apply_service.new(user, *suggestions).execute
|
||||
def apply(suggestions, custom_message = nil)
|
||||
result = apply_service.new(user, *suggestions, message: custom_message).execute
|
||||
|
||||
suggestions.map { |suggestion| suggestion.reload }
|
||||
|
||||
|
@ -111,6 +111,16 @@ RSpec.describe Suggestions::ApplyService do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a user suggested commit message' do
|
||||
let(:message) { "i'm a custom commit message!" }
|
||||
|
||||
it "uses the user's commit message" do
|
||||
apply(suggestions, message)
|
||||
|
||||
expect(project.repository.commit.message).to(eq(message))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subject(:apply_service) { described_class }
|
||||
|
|
|
@ -46,7 +46,7 @@ module SnowplowHelpers
|
|||
# }
|
||||
# ]
|
||||
# )
|
||||
def expect_snowplow_event(category:, action:, context: nil, **kwargs)
|
||||
def expect_snowplow_event(category:, action:, context: nil, standard_context: nil, **kwargs)
|
||||
if context
|
||||
kwargs[:context] = []
|
||||
context.each do |c|
|
||||
|
@ -56,6 +56,14 @@ module SnowplowHelpers
|
|||
end
|
||||
end
|
||||
|
||||
if standard_context
|
||||
expect(Gitlab::Tracking::StandardContext)
|
||||
.to have_received(:new)
|
||||
.with(**standard_context)
|
||||
|
||||
kwargs[:standard_context] = an_instance_of(Gitlab::Tracking::StandardContext)
|
||||
end
|
||||
|
||||
expect(Gitlab::Tracking).to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking
|
||||
.with(category, action, **kwargs).at_least(:once)
|
||||
end
|
||||
|
|
|
@ -18,6 +18,7 @@ RSpec.configure do |config|
|
|||
stub_application_setting(snowplow_enabled: true)
|
||||
|
||||
allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original
|
||||
allow(Gitlab::Tracking::StandardContext).to receive(:new).and_call_original
|
||||
allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking
|
||||
end
|
||||
|
||||
|
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe VariableDuplicatesValidator do
|
||||
let(:validator) { described_class.new(attributes: [:variables], **options) }
|
||||
RSpec.describe NestedAttributesDuplicatesValidator do
|
||||
let(:validator) { described_class.new(attributes: [attribute], **options) }
|
||||
|
||||
describe '#validate_each' do
|
||||
let(:project) { build(:project) }
|
||||
let(:record) { project }
|
||||
let(:attribute) { :variables }
|
||||
let(:value) { project.variables }
|
||||
|
||||
subject { validator.validate_each(project, :variables, project.variables) }
|
||||
subject { validator.validate_each(record, attribute, value) }
|
||||
|
||||
context 'with no scope' do
|
||||
let(:options) { {} }
|
||||
|
@ -65,5 +68,46 @@ RSpec.describe VariableDuplicatesValidator do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a child attribute' do
|
||||
let(:release) { build(:release) }
|
||||
let(:first_link) { build(:release_link, name: 'test1', url: 'https://www.google1.com', release: release) }
|
||||
let(:second_link) { build(:release_link, name: 'test2', url: 'https://www.google2.com', release: release) }
|
||||
let(:record) { release }
|
||||
let(:attribute) { :links }
|
||||
let(:value) { release.links }
|
||||
let(:options) { { scope: :release, child_attributes: %i[name url] } }
|
||||
|
||||
before do
|
||||
release.links << first_link
|
||||
release.links << second_link
|
||||
end
|
||||
|
||||
it 'does not have any errors' do
|
||||
subject
|
||||
|
||||
expect(release.errors.empty?).to be true
|
||||
end
|
||||
|
||||
context 'when name is duplicated' do
|
||||
let(:second_link) { build(:release_link, name: 'test1', release: release) }
|
||||
|
||||
it 'has a duplicate error' do
|
||||
subject
|
||||
|
||||
expect(release.errors).to have_key(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when url is duplicated' do
|
||||
let(:second_link) { build(:release_link, url: 'https://www.google1.com', release: release) }
|
||||
|
||||
it 'has a duplicate error' do
|
||||
subject
|
||||
|
||||
expect(release.errors).to have_key(attribute)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue