Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
efa1bb6a1f
commit
4ea1233d2e
|
|
@ -12,7 +12,6 @@ Layout/ArrayAlignment:
|
|||
- 'ee/app/models/concerns/geo/verification_state.rb'
|
||||
- 'ee/app/models/dast_site_profile.rb'
|
||||
- 'ee/app/models/dora/base_metric.rb'
|
||||
- 'ee/app/models/ee/application_setting.rb'
|
||||
- 'ee/app/models/ee/epic.rb'
|
||||
- 'ee/app/models/ee/user.rb'
|
||||
- 'ee/app/models/geo/event_log.rb'
|
||||
|
|
|
|||
|
|
@ -617,7 +617,6 @@ Layout/LineLength:
|
|||
- 'ee/app/models/dast/site_profile_secret_variable.rb'
|
||||
- 'ee/app/models/dora/lead_time_for_changes_metric.rb'
|
||||
- 'ee/app/models/ee/alert_management/alert.rb'
|
||||
- 'ee/app/models/ee/application_setting.rb'
|
||||
- 'ee/app/models/ee/ci/build.rb'
|
||||
- 'ee/app/models/ee/ci/daily_build_group_report_result.rb'
|
||||
- 'ee/app/models/ee/ci/job_artifact.rb'
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ Lint/UnusedBlockArgument:
|
|||
- 'ee/app/graphql/resolvers/incident_management/escalation_policies_resolver.rb'
|
||||
- 'ee/app/graphql/resolvers/incident_management/oncall_rotations_resolver.rb'
|
||||
- 'ee/app/graphql/types/health_status_enum.rb'
|
||||
- 'ee/app/models/ee/application_setting.rb'
|
||||
- 'ee/app/models/ee/ci/build.rb'
|
||||
- 'ee/app/models/ee/epic.rb'
|
||||
- 'ee/app/models/ee/namespace_setting.rb'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
RSpec/BeEq:
|
||||
Exclude:
|
||||
- 'ee/spec/bin/custom_ability_spec.rb'
|
||||
- 'ee/spec/controllers/admin/application_settings_controller_spec.rb'
|
||||
- 'ee/spec/controllers/admin/audit_log_reports_controller_spec.rb'
|
||||
- 'ee/spec/controllers/concerns/ee/routable_actions/sso_enforcement_redirect_spec.rb'
|
||||
- 'ee/spec/controllers/concerns/routable_actions_spec.rb'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
RSpec/ContextWording:
|
||||
Exclude:
|
||||
- 'ee/spec/controllers/admin/application_settings_controller_spec.rb'
|
||||
- 'ee/spec/controllers/admin/audit_logs_controller_spec.rb'
|
||||
- 'ee/spec/controllers/admin/dev_ops_report_controller_spec.rb'
|
||||
- 'ee/spec/controllers/admin/emails_controller_spec.rb'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Cop supports --autocorrect.
|
||||
RSpec/Dialect:
|
||||
Exclude:
|
||||
- 'ee/spec/controllers/admin/application_settings_controller_spec.rb'
|
||||
- 'ee/spec/helpers/ee/personal_access_tokens_helper_spec.rb'
|
||||
- 'ee/spec/lib/ee/gitlab/gon_helper_spec.rb'
|
||||
- 'ee/spec/lib/ee/gitlab/saas_spec.rb'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ RSpec/ReceiveMessages:
|
|||
- 'ee/spec/bin/custom_ability_spec.rb'
|
||||
- 'ee/spec/components/namespaces/storage/limit_alert_component_spec.rb'
|
||||
- 'ee/spec/components/namespaces/storage/repository_limit_alert_component_spec.rb'
|
||||
- 'ee/spec/controllers/admin/application_settings_controller_spec.rb'
|
||||
- 'ee/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb'
|
||||
- 'ee/spec/controllers/concerns/audit_events/audit_events_params_spec.rb'
|
||||
- 'ee/spec/controllers/ee/search_controller_spec.rb'
|
||||
|
|
|
|||
|
|
@ -242,7 +242,6 @@ Style/GuardClause:
|
|||
- 'ee/app/models/dast/profile.rb'
|
||||
- 'ee/app/models/dast_site.rb'
|
||||
- 'ee/app/models/dast_site_profile.rb'
|
||||
- 'ee/app/models/ee/application_setting.rb'
|
||||
- 'ee/app/models/ee/epic.rb'
|
||||
- 'ee/app/models/ee/group_group_link.rb'
|
||||
- 'ee/app/models/ee/group_member.rb'
|
||||
|
|
|
|||
|
|
@ -254,7 +254,6 @@ Style/IfUnlessModifier:
|
|||
- 'ee/app/models/concerns/geo/verifiable_replicator.rb'
|
||||
- 'ee/app/models/concerns/security/scan_execution_policy.rb'
|
||||
- 'ee/app/models/dast_site_profile.rb'
|
||||
- 'ee/app/models/ee/application_setting.rb'
|
||||
- 'ee/app/models/ee/ci/job_artifact.rb'
|
||||
- 'ee/app/models/ee/group.rb'
|
||||
- 'ee/app/models/ee/issue.rb'
|
||||
|
|
|
|||
|
|
@ -12,32 +12,12 @@ module Emails
|
|||
)
|
||||
end
|
||||
|
||||
def project_import_complete(project_id, user_id, user_mapping_enabled)
|
||||
user = User.find(user_id)
|
||||
@project = Project.find(project_id)
|
||||
@namespace = @project.root_ancestor
|
||||
@hostname = @project.import_url
|
||||
@user_mapping_available = user_mapping_enabled && !@namespace.user_namespace? && !@project.import_failed?
|
||||
@is_group_owner = user.can?(:admin_namespace, @namespace)
|
||||
@is_project_creator = user_id == @project.creator_id
|
||||
|
||||
title = safe_format(
|
||||
s_('Import|Import from %{hostname} completed'),
|
||||
hostname: @hostname
|
||||
)
|
||||
|
||||
email_with_layout(
|
||||
to: user.notification_email_or_default,
|
||||
subject: subject(title)
|
||||
)
|
||||
end
|
||||
|
||||
def bulk_import_complete(user_id, bulk_import_id)
|
||||
user = User.find(user_id)
|
||||
@bulk_import = BulkImport.find(bulk_import_id)
|
||||
@hostname = @bulk_import.configuration.url
|
||||
title = safe_format(
|
||||
s_('Import|Import from %{hostname} completed'),
|
||||
s_('BulkImport|Import from %{hostname} completed'),
|
||||
hostname: @hostname
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -389,13 +389,6 @@ class NotifyPreview < ActionMailer::Preview
|
|||
).message
|
||||
end
|
||||
|
||||
def project_import_complete
|
||||
project_id = ProjectImportState.last.project_id
|
||||
creator_id = Project.find(project_id).creator_id
|
||||
|
||||
Notify.project_import_complete(project_id, creator_id, true).message
|
||||
end
|
||||
|
||||
def bulk_import_complete
|
||||
bulk_import = BulkImport.last
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ module PartitionedTable
|
|||
|
||||
PARTITIONING_STRATEGIES = {
|
||||
daily: Gitlab::Database::Partitioning::Time::DailyStrategy,
|
||||
monthly: Gitlab::Database::Partitioning::MonthlyStrategy,
|
||||
monthly: Gitlab::Database::Partitioning::Time::MonthlyStrategy,
|
||||
sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy,
|
||||
ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy,
|
||||
int_range: Gitlab::Database::Partitioning::IntRangeStrategy
|
||||
|
|
|
|||
|
|
@ -32,10 +32,6 @@ module Namespaces
|
|||
Array.wrap(owner)
|
||||
end
|
||||
|
||||
def owner_ids
|
||||
owners.map(&:id)
|
||||
end
|
||||
|
||||
def member?(user, min_access_level = Gitlab::Access::GUEST)
|
||||
return false unless user
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ class ProjectImportState < ApplicationRecord
|
|||
|
||||
self.table_name = "project_mirror_data"
|
||||
|
||||
attr_accessor :user_mapping_enabled
|
||||
|
||||
after_commit :expire_etag_cache
|
||||
|
||||
belongs_to :project, inverse_of: :import_state
|
||||
|
|
@ -68,10 +66,7 @@ class ProjectImportState < ApplicationRecord
|
|||
end
|
||||
|
||||
after_transition any => [:canceled, :failed] do |state, _|
|
||||
project = state.project
|
||||
|
||||
state.user_mapping_enabled = project.import_data&.user_mapping_enabled?
|
||||
project.remove_import_data
|
||||
state.project.remove_import_data
|
||||
end
|
||||
|
||||
before_transition started: [:finished, :canceled, :failed] do |state, _|
|
||||
|
|
@ -87,7 +82,6 @@ class ProjectImportState < ApplicationRecord
|
|||
after_transition started: :finished do |state, _|
|
||||
project = state.project
|
||||
|
||||
state.user_mapping_enabled = project.import_data&.user_mapping_enabled?
|
||||
project.reset_cache_and_import_attrs
|
||||
|
||||
if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists?
|
||||
|
|
@ -95,12 +89,6 @@ class ProjectImportState < ApplicationRecord
|
|||
Projects::AfterImportWorker.perform_async(project.id)
|
||||
end
|
||||
end
|
||||
|
||||
state.send_completion_notification
|
||||
end
|
||||
|
||||
after_transition any => [:failed] do |state, _|
|
||||
state.send_completion_notification(notify_group_owners: false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -159,28 +147,6 @@ class ProjectImportState < ApplicationRecord
|
|||
# import? does SQL work so only run it if it looks like there's an import running
|
||||
status == 'started' && project.import?
|
||||
end
|
||||
|
||||
def send_completion_notification(notify_group_owners: true)
|
||||
run_after_commit do
|
||||
root_ancestor = project.root_ancestor
|
||||
|
||||
notified_user_ids = [project.creator_id]
|
||||
notified_user_ids |= root_ancestor.owner_ids if user_mapping_enabled? && notify_group_owners
|
||||
|
||||
notified_user_ids.each do |user_id|
|
||||
Notify.project_import_complete(project_id, user_id, user_mapping_enabled?).deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Return whether or not user mapping was enabled during the project's import to determine who to
|
||||
# send completion emails to. user_mapping_enabled should be set if import_data is removed.
|
||||
# This can be removed when all 3rd party project importer user mapping feature flags are removed.
|
||||
def user_mapping_enabled?
|
||||
user_mapping_enabled || project.import_data&.user_mapping_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
ProjectImportState.prepend_mod_with('ProjectImportState')
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ module DraftNotes
|
|||
keep_around_commits(created_notes)
|
||||
draft_notes.delete_all
|
||||
notification_service.async.new_review(review)
|
||||
todo_service.new_review(review, current_user)
|
||||
MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request)
|
||||
GraphqlTriggers.merge_request_merge_status_updated(merge_request)
|
||||
after_publish
|
||||
|
|
|
|||
|
|
@ -187,6 +187,14 @@ class TodoService
|
|||
create_ssh_key_todos(ssh_keys, ::Todo::SSH_KEY_EXPIRED)
|
||||
end
|
||||
|
||||
# When a merge request receives a review
|
||||
#
|
||||
# * Mark all outstanding todos on this MR for the current user as done
|
||||
#
|
||||
def new_review(review, current_user)
|
||||
resolve_todos_for_target(review.merge_request, current_user)
|
||||
end
|
||||
|
||||
# When user marks a target as todo
|
||||
def mark_todo(target, current_user)
|
||||
project = target.project
|
||||
|
|
|
|||
|
|
@ -4,13 +4,24 @@ module WorkItems
|
|||
module DataSync
|
||||
module Widgets
|
||||
class TimeTracking < Base
|
||||
def before_create
|
||||
return unless target_work_item.get_widget(:time_tracking)
|
||||
|
||||
target_work_item.time_estimate = work_item.time_estimate
|
||||
end
|
||||
|
||||
def after_save_commit
|
||||
# copy time tracking data, e.g.
|
||||
# WorkItems::CopyTimelogsWorker.perform_async(work_item_id, target_work_item_id)
|
||||
return unless params[:operation] == :move
|
||||
return unless target_work_item.get_widget(:time_tracking)
|
||||
return if work_item.timelogs.empty?
|
||||
|
||||
WorkItems::CopyTimelogsWorker.perform_async(work_item.id, target_work_item.id)
|
||||
end
|
||||
|
||||
def post_move_cleanup
|
||||
# do it
|
||||
work_item.timelogs.each_batch(of: BATCH_SIZE) do |timelogs|
|
||||
timelogs.delete_all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
checkbox_options: { checked: group.namespace_settings&.remove_dormant_members }
|
||||
.form-group
|
||||
= f.label :remove_dormant_members_period, _('Days of inactivity before removal'), class: 'label-light'
|
||||
= f.number_field :remove_dormant_members_period, class: 'form-control w-auto gl-form-input', min: 90,
|
||||
= f.number_field :remove_dormant_members_period, class: 'form-control w-auto gl-form-input', min: 90, max: 1827,
|
||||
required: true, value: group.namespace_settings&.remove_dormant_members_period,
|
||||
disabled: !group.namespace_settings&.remove_dormant_members?
|
||||
#group_remove_dormant_members_period_error.form-text.gl-text-red-500.gl-hidden
|
||||
= _('Please enter a value of 90 days or more')
|
||||
= _('Please enter a value between 90 and 1827 days (5 years).')
|
||||
.form-text.gl-text-subtle
|
||||
= _('Must be 90 days or more.')
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
- header_style = 'font-size:24px; text-align:center; line-height:30px;'
|
||||
- text_style = 'font-size:16px; text-align:center; line-height:24px; margin-top: 24px;'
|
||||
- button_style = 'border: 1px solid #694cc0; border-radius: 4px; font-size: 14px; padding: 8px 16px; background-color: #7b58cf; color: #fff; cursor: pointer;'
|
||||
|
||||
- strong_tag_pair = tag_pair(tag.strong, :strong_open, :strong_close)
|
||||
- start_date = l(@project.created_at.to_date, format: :long)
|
||||
|
||||
%h1{ style: header_style }
|
||||
= s_('Import|Import completed')
|
||||
|
||||
%p{ style: text_style }
|
||||
- if @is_project_creator
|
||||
= safe_format(s_('Import|The import you started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed.'),
|
||||
strong_tag_pair,
|
||||
hostname: @hostname,
|
||||
start_date: start_date)
|
||||
- else
|
||||
= safe_format(s_('Import|The import %{project_creator_name} started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed.'),
|
||||
strong_tag_pair,
|
||||
project_creator_name: sanitize_name(@project.creator.name),
|
||||
hostname: @hostname,
|
||||
start_date: start_date)
|
||||
|
||||
- if !@user_mapping_available
|
||||
= s_('Import|You can now review your import results.')
|
||||
- elsif @is_group_owner
|
||||
= s_('Import|You can reassign contributions on the "Members" page of the group.')
|
||||
- else
|
||||
= s_('Import|Users with the Owner role for the group can reassign contributions on the "Members" page.')
|
||||
|
||||
%p{ style: text_style }
|
||||
- if @user_mapping_available && @is_group_owner
|
||||
= link_to group_group_members_url(@namespace, tab: 'placeholders'), target: '_blank', rel: 'noopener noreferrer' do
|
||||
%button{ type: 'button', style: button_style }
|
||||
= s_('Import|Reassign contributions')
|
||||
- else
|
||||
= link_to import_history_index_url, target: '_blank', rel: 'noopener noreferrer' do
|
||||
%button{ type: 'button', style: button_style }
|
||||
= s_('Import|View import results')
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<%= s_('Import|Import completed') %>
|
||||
|
||||
<% if @is_project_creator %>
|
||||
<%= safe_format(s_('Import|The import you started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed.'), strong_open: '', strong_close: '', hostname: @hostname, start_date: l(@project.created_at.to_date, format: :long)) -%>
|
||||
<% else %>
|
||||
<%= safe_format(s_('Import|The import %{project_creator_name} started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed.'), strong_open: '', strong_close: '', hostname: @hostname, project_creator_name: sanitize_name(@project.creator.name), start_date: l(@project.created_at.to_date, format: :long)) -%>
|
||||
<% end %>
|
||||
<% if !@user_mapping_available %>
|
||||
<%= s_('Import|You can now review your import results.') %>
|
||||
<% elsif @is_group_owner %>
|
||||
<%= s_('Import|You can reassign contributions on the "Members" page of the group.') %>
|
||||
<% else %>
|
||||
<%= s_('Import|Users with the Owner role for the group can reassign contributions on the "Members" page.') %>
|
||||
<% end %>
|
||||
|
||||
<% if @user_mapping_available && @is_group_owner %>
|
||||
<%= s_('Import|Reassign contributions') %>: <%= group_group_members_url(@namespace, tab: 'placeholders') %>
|
||||
<% else %>
|
||||
<%= s_('Import|View import results') %>: <%= import_history_index_url %>
|
||||
<% end %>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
stage: AI-powered
|
||||
group: AI Framework
|
||||
description: AI-powered features and functionality.
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Get started with GitLab Duo
|
||||
|
||||
GitLab Duo is your AI-powered assistant. It can help you write, review, and edit code,
|
||||
along with a variety of other tasks throughout your GitLab workflow.
|
||||
It can help you troubleshoot your pipeline, write tests, address vulnerabilities, and more.
|
||||
|
||||
## Step 1: Ensure you have a subscription
|
||||
|
||||
Your organization has purchased a GitLab Duo add-on subscription: Either Duo Pro or Duo Enterprise.
|
||||
Each subscription includes a set of AI-powered features to help improve your workflow.
|
||||
|
||||
After your organization purchases a subscription, an administrator must assign seats to users.
|
||||
You likely received an email that notified you of your seat.
|
||||
|
||||
The AI-powered features you have access to use large language models (LLMs) to help streamline
|
||||
your workflow. If you're on self-managed GitLab, your administrator can choose to use
|
||||
GitLab models, or self-host their own models.
|
||||
|
||||
If you have issues accessing GitLab Duo features, ask your administrator.
|
||||
They can check the health of the installation.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Assign seats to users](../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats).
|
||||
- [Features included in Duo Pro and Duo Enterprise](https://about.gitlab.com/gitlab-duo/#pricing).
|
||||
- [List of GitLab Duo features and their LLMs](../gitlab_duo/index.md).
|
||||
- [Self-hosted models](../../administration/self_hosted_models/index.md).
|
||||
- [Health check details](../gitlab_duo/turn_on_off.md#run-a-health-check-for-gitlab-duo).
|
||||
|
||||
## Step 2: Try Duo Chat in the UI
|
||||
|
||||
Confirm that Duo Chat is available in the GitLab UI.
|
||||
|
||||
Go to a project and in the upper-right corner, a button named **Ask Duo Chat** should be displayed.
|
||||
If this button is available, it means everything is configured properly.
|
||||
Try asking Duo Chat a question or type `/` to see a list of slash commands.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [GitLab Duo Chat](../gitlab_duo_chat/index.md).
|
||||
|
||||
## Step 3: Try other GitLab Duo features
|
||||
|
||||
GitLab Duo is available at different points in your workflow. From troubleshooting
|
||||
CI/CD pipelines to writing test cases and reviewing code, GitLab Duo can help you
|
||||
in a variety of ways.
|
||||
|
||||
If you want to test a feature, you can go to a failed CI/CD job and at the bottom
|
||||
of the page, select **Troubleshoot**.
|
||||
|
||||
Or, in an issue that has a lot of comments, in the **Activity** section, select **View summary**.
|
||||
GitLab Duo summarizes the contents of the issue.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [The complete list of GitLab Duo features](../gitlab_duo/index.md).
|
||||
- [Turn on GitLab Duo features that are still in development](../gitlab_duo/turn_on_off.md#turn-on-beta-and-experimental-features).
|
||||
|
||||
## Step 4: Prepare to use GitLab Duo in your IDE
|
||||
|
||||
To use GitLab Duo, including Code Suggestions, in your IDE, you must:
|
||||
|
||||
- Install an extension in your IDE.
|
||||
- Authenticate with GitLab from the IDE. You can use either OAuth or a personal access token.
|
||||
|
||||
Then you can confirm that GitLab Duo is available in your IDE and test some of the features.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Set up the extension for VS Code](../../editor_extensions/visual_studio_code/index.md#set-up-the-gitlab-workflow-extension).
|
||||
- [Set up the extension for JetBrains](../../editor_extensions/jetbrains_ide/index.md#download-the-extension).
|
||||
- [Set up the extension for Visual Studio](../../editor_extensions/visual_studio/index.md).
|
||||
- [Set up the extension for Neovim](../../editor_extensions/neovim/index.md#install-the-extension).
|
||||
|
||||
## Step 5: Turn on Code Suggestions in your IDE
|
||||
|
||||
Finally, go to the settings for the extension and confirm that Code Suggestions is enabled,
|
||||
as well as the languages you want suggestions for.
|
||||
|
||||
You should also confirm that Duo Chat is enabled.
|
||||
|
||||
Then test Code Suggestions and Duo Chat in your IDE.
|
||||
|
||||
- Code Suggestions recommends code as you type.
|
||||
- Duo Chat is available to ask questions about your code or anything else you need.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Supported extensions and languages](../project/repository/code_suggestions/supported_extensions.md).
|
||||
- [Code Suggestions](../project/repository/code_suggestions/index.md#use-code-suggestions).
|
||||
|
|
@ -17,12 +17,13 @@ These features aim to help increase velocity and solve key pain points across th
|
|||
GitLab Duo features are available in [IDE extensions](../../editor_extensions/index.md) and the GitLab UI.
|
||||
Some features are also available as part of [GitLab Duo Chat](../gitlab_duo_chat_examples.md).
|
||||
|
||||
- [Get started with GitLab Duo](../get_started/getting_started_gitlab_duo.md).
|
||||
- [View a walkthrough of GitLab Duo Enterprise features](https://gitlab.navattic.com/duo-enterprise).
|
||||
|
||||
GitLab is [transparent](https://handbook.gitlab.com/handbook/values/#transparency).
|
||||
As GitLab Duo features mature, the documentation will be updated to clearly state
|
||||
how and where you can access these features.
|
||||
|
||||
**{play}** [View a walkthrough of GitLab Duo Enterprise features](https://gitlab.navattic.com/duo-enterprise).
|
||||
|
||||
## Generally available features
|
||||
|
||||
### GitLab Duo Chat
|
||||
|
|
|
|||
|
|
@ -1,50 +1,13 @@
|
|||
---
|
||||
stage: AI-powered
|
||||
group: AI Framework
|
||||
description: AI-powered features and functionality.
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
ignore_in_report: true
|
||||
redirect_to: '../get_started/getting_started_gitlab_duo.md'
|
||||
remove_date: '2025-02-21'
|
||||
---
|
||||
|
||||
# Get started with GitLab Duo Code Suggestions
|
||||
<!-- markdownlint-disable -->
|
||||
|
||||
GitLab Duo is an AI-powered enhancement integrated into the GitLab DevSecOps platform, designed to boost productivity and efficiency. It leverages advanced large language models (LLMs) from Google Vertex AI and Anthropic Claude to provide various AI-driven features. These features operate independently, ensuring that the core functionality of GitLab remains unaffected even if an AI feature encounters issues.
|
||||
This document was moved to [another location](../get_started/getting_started_gitlab_duo.md).
|
||||
|
||||
GitLab Duo Code Suggestions helps you write code more efficiently by using generative AI to suggest code while you’re developing.
|
||||
|
||||
## Step 1: Understand how Code Suggestions works
|
||||
|
||||
Code Suggestions is made up of code completion and code generation. These are two distinct features that help developers write code more efficiently:
|
||||
|
||||
| | Code completion | Code generation |
|
||||
| :---- | :---- | :---- |
|
||||
| Purpose | Provides suggestions for completing the current line of code. | Generates new code based on a natural language comment. |
|
||||
| Trigger | Triggers when typing, usually with a short delay. | Triggers when pressing Enter after writing a comment that includes specific keywords. |
|
||||
| Scope | Limited to the current line or small block of code. | Can generate entire methods, functions, or even classes based on the context. |
|
||||
| Accuracy | More accurate for simple tasks and short blocks of code. | Can be more accurate for complex tasks and large blocks of code due to its ability to analyze context and use a larger language model. |
|
||||
| When to use | Use code completion for quick tasks, small code snippets, or when you need a slight boost in productivity. | Use code generation for more complex tasks, larger codebases, or when you want to write new code from scratch based on a natural language description. |
|
||||
|
||||
## Step 2: Supported languages, text editors and IDEs
|
||||
|
||||
To ensure your preferred tooling is supported, review the:
|
||||
|
||||
[Supported languages](../project/repository/code_suggestions/supported_extensions.md#supported-languages)
|
||||
[Supported editors](../project/repository/code_suggestions/supported_extensions.md#supported-editor-extensions)
|
||||
|
||||
## Step 3: Enable Code Suggestions
|
||||
|
||||
First, [purchase seats for GitLab Duo](../../subscriptions/subscription-add-ons.md#purchase-gitlab-duo).
|
||||
|
||||
Then, assign seats to users to grant access to GitLab Duo for:
|
||||
|
||||
- [Self-managed](../../subscriptions/subscription-add-ons.md#for-self-managed)
|
||||
- [Dedicated](../../subscriptions/subscription-add-ons.md#for-self-managed)
|
||||
- [GitLab.com](../../subscriptions/subscription-add-ons.md#for-gitlabcom-1)
|
||||
|
||||
## Step 4: Using Code Suggestions
|
||||
|
||||
Follow the documentation to [use Code Suggestions](../project/repository/code_suggestions/index.md#use-code-suggestions).
|
||||
|
||||
## Step 5: Troubleshooting common challenges
|
||||
|
||||
[Troubleshoot](../project/repository/code_suggestions/troubleshooting.md) commonly faced challenges.
|
||||
<!-- This redirect file can be deleted after <2025-02-21>. -->
|
||||
<!-- Redirects that point to other docs in the same project expire in three months. -->
|
||||
<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. -->
|
||||
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Database
|
||||
module Partitioning
|
||||
class MonthlyStrategy
|
||||
attr_reader :model, :partitioning_key, :retain_for, :retain_non_empty_partitions, :analyze_interval
|
||||
|
||||
# We create this many partitions in the future
|
||||
HEADROOM = 6.months
|
||||
|
||||
delegate :table_name, to: :model
|
||||
|
||||
def initialize(
|
||||
model, partitioning_key, retain_for: nil, retain_non_empty_partitions: false,
|
||||
analyze_interval: nil)
|
||||
@model = model
|
||||
@partitioning_key = partitioning_key
|
||||
@retain_for = retain_for
|
||||
@retain_non_empty_partitions = retain_non_empty_partitions
|
||||
@analyze_interval = analyze_interval
|
||||
end
|
||||
|
||||
def current_partitions
|
||||
Gitlab::Database::PostgresPartition.for_parent_table(table_name).map do |partition|
|
||||
TimePartition.from_sql(table_name, partition.name, partition.condition)
|
||||
end
|
||||
end
|
||||
|
||||
# Check the currently existing partitions and determine which ones are missing
|
||||
def missing_partitions
|
||||
desired_partitions - current_partitions
|
||||
end
|
||||
|
||||
def extra_partitions
|
||||
partitions = current_partitions - desired_partitions
|
||||
partitions.reject!(&:holds_data?) if retain_non_empty_partitions
|
||||
|
||||
partitions
|
||||
end
|
||||
|
||||
def after_adding_partitions
|
||||
# No-op, required by the partition manager
|
||||
end
|
||||
|
||||
def validate_and_fix
|
||||
# No-op, required by the partition manager
|
||||
end
|
||||
|
||||
def partition_name(lower_bound)
|
||||
suffix = lower_bound&.strftime('%Y%m') || '000000'
|
||||
|
||||
"#{table_name}_#{suffix}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def desired_partitions
|
||||
[].tap do |parts|
|
||||
min_date, max_date = relevant_range
|
||||
|
||||
if pruning_old_partitions? && min_date <= oldest_active_date
|
||||
min_date = oldest_active_date.beginning_of_month
|
||||
else
|
||||
parts << partition_for(upper_bound: min_date)
|
||||
end
|
||||
|
||||
while min_date < max_date
|
||||
next_date = min_date.next_month
|
||||
|
||||
parts << partition_for(lower_bound: min_date, upper_bound: next_date)
|
||||
|
||||
min_date = next_date
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This determines the relevant time range for which we expect to have data
|
||||
# (and therefore need to create partitions for).
|
||||
#
|
||||
# Note: We typically expect the first partition to be half-unbounded, i.e.
|
||||
# to start from MINVALUE to a specific date `x`. The range returned
|
||||
# does not include the range of the first, half-unbounded partition.
|
||||
def relevant_range
|
||||
if (first_partition = current_partitions.min)
|
||||
# Case 1: First partition starts with MINVALUE, i.e. from is nil -> start with first real partition
|
||||
# Case 2: Rather unexpectedly, first partition does not start with MINVALUE, i.e. from is not nil
|
||||
# In this case, use first partition beginning as a start
|
||||
min_date = first_partition.from || first_partition.to
|
||||
end
|
||||
|
||||
if pruning_old_partitions?
|
||||
min_date ||= oldest_active_date
|
||||
end
|
||||
|
||||
# In case we don't have a partition yet
|
||||
min_date ||= Date.today
|
||||
min_date = min_date.beginning_of_month
|
||||
|
||||
max_date = Date.today.end_of_month + HEADROOM
|
||||
|
||||
[min_date, max_date]
|
||||
end
|
||||
|
||||
def partition_for(upper_bound:, lower_bound: nil)
|
||||
TimePartition.new(table_name, lower_bound, upper_bound, partition_name: partition_name(lower_bound))
|
||||
end
|
||||
|
||||
def pruning_old_partitions?
|
||||
retain_for.present?
|
||||
end
|
||||
|
||||
def oldest_active_date
|
||||
(Date.today - retain_for).beginning_of_month
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Database
|
||||
module Partitioning
|
||||
module Time
|
||||
class MonthlyStrategy < BaseStrategy
|
||||
HEADROOM = 6.months
|
||||
PARTITION_SUFFIX = '%Y%m'
|
||||
|
||||
def current_partitions
|
||||
Gitlab::Database::PostgresPartition.for_parent_table(table_name).map do |partition|
|
||||
TimePartition.from_sql(table_name, partition.name, partition.condition)
|
||||
end
|
||||
end
|
||||
|
||||
# Check the currently existing partitions and determine which ones are missing
|
||||
def missing_partitions
|
||||
desired_partitions - current_partitions
|
||||
end
|
||||
|
||||
def extra_partitions
|
||||
partitions = current_partitions - desired_partitions
|
||||
partitions.reject!(&:holds_data?) if retain_non_empty_partitions
|
||||
|
||||
partitions
|
||||
end
|
||||
|
||||
def partition_name(lower_bound)
|
||||
suffix = lower_bound&.strftime(PARTITION_SUFFIX) || '000000'
|
||||
|
||||
"#{table_name}_#{suffix}"
|
||||
end
|
||||
|
||||
def desired_partitions
|
||||
[].tap do |parts|
|
||||
min_date, max_date = relevant_range
|
||||
|
||||
if pruning_old_partitions? && min_date <= oldest_active_date
|
||||
min_date = oldest_active_date.beginning_of_month
|
||||
else
|
||||
parts << partition_for(upper_bound: min_date)
|
||||
end
|
||||
|
||||
while min_date < max_date
|
||||
next_date = min_date.next_month
|
||||
|
||||
parts << partition_for(lower_bound: min_date, upper_bound: next_date)
|
||||
|
||||
min_date = next_date
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This determines the relevant time range for which we expect to have data
|
||||
# (and therefore need to create partitions for).
|
||||
#
|
||||
# Note: We typically expect the first partition to be half-unbounded, i.e.
|
||||
# to start from MINVALUE to a specific date `x`. The range returned
|
||||
# does not include the range of the first, half-unbounded partition.
|
||||
def relevant_range
|
||||
first_partition = current_partitions.min
|
||||
|
||||
if first_partition
|
||||
# Case 1: First partition starts with MINVALUE, i.e. from is nil -> start with first real partition
|
||||
# Case 2: Rather unexpectedly, first partition does not start with MINVALUE, i.e. from is not nil
|
||||
# In this case, use first partition beginning as a start
|
||||
min_date = first_partition.from || first_partition.to
|
||||
end
|
||||
|
||||
min_date ||= oldest_active_date if pruning_old_partitions?
|
||||
|
||||
# In case we don't have a partition yet
|
||||
min_date ||= Date.current
|
||||
min_date = min_date.beginning_of_month
|
||||
|
||||
max_date = Date.current.end_of_month + HEADROOM
|
||||
|
||||
[min_date, max_date]
|
||||
end
|
||||
|
||||
def oldest_active_date
|
||||
retain_for.ago.beginning_of_month.to_date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -10251,6 +10251,9 @@ msgstr ""
|
|||
msgid "BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import from %{hostname} completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import groups by direct transfer"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -28383,12 +28386,6 @@ msgstr ""
|
|||
msgid "Import|GitHub import details"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Import completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Import from %{hostname} completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Import source user has an invalid status for this operation"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -28422,18 +28419,9 @@ msgstr ""
|
|||
msgid "Import|Partially completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Reassign contributions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Show errors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|The import %{project_creator_name} started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|The import you started on %{start_date} from %{strong_open}%{hostname}%{strong_close} has completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|The repository could not be imported."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -28449,21 +28437,9 @@ msgstr ""
|
|||
msgid "Import|Timeout for decompressing archived files."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|Users with the Owner role for the group can reassign contributions on the \"Members\" page."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|View import results"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|You can import a Subversion repository by using third-party tools. %{svn_link}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|You can now review your import results."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|You can reassign contributions on the \"Members\" page of the group."
|
||||
msgstr ""
|
||||
|
||||
msgid "Import|You do not have permission to view import source users for this namespace"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -41226,6 +41202,9 @@ msgstr ""
|
|||
msgid "Please enter a valid time interval"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter a value between 90 and 1827 days (5 years)."
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter a value of 90 days or more"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -347,8 +347,6 @@ spec/frontend/vue_shared/components/file_finder/index_spec.js
|
|||
spec/frontend/vue_shared/components/file_tree_spec.js
|
||||
spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
|
||||
spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js
|
||||
spec/frontend/vue_shared/components/list_selector/group_item_spec.js
|
||||
spec/frontend/vue_shared/components/list_selector/user_item_spec.js
|
||||
spec/frontend/vue_shared/components/markdown/header_spec.js
|
||||
spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
|
||||
spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js
|
||||
|
|
|
|||
|
|
@ -1,728 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "Admin Runners", :freeze_time, feature_category: :fleet_visibility do
|
||||
include Features::SortingHelpers
|
||||
include Features::RunnersHelpers
|
||||
include Spec::Support::Helpers::ModalHelpers
|
||||
|
||||
let_it_be(:admin) { create(:admin) }
|
||||
|
||||
before do
|
||||
sign_in(admin)
|
||||
enable_admin_mode!(admin)
|
||||
end
|
||||
|
||||
describe "Admin Runners page", :js do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:namespace) { create(:namespace) }
|
||||
let_it_be(:project) { create(:project, namespace: namespace, creator: user) }
|
||||
|
||||
describe "runners creation and registration" do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows a create button' do
|
||||
expect(page).to have_link s_('Runner|New instance runner'), href: new_admin_runner_path
|
||||
end
|
||||
|
||||
it_behaves_like "shows and resets runner registration token" do
|
||||
let(:dropdown_text) { s_('Runners|Register an instance runner') }
|
||||
let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are runners" do
|
||||
context "with an instance runner" do
|
||||
let_it_be(:instance_runner) { create(:ci_runner, :instance) }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it_behaves_like 'shows runner summary and navigates to details' do
|
||||
let(:runner) { instance_runner }
|
||||
let(:runner_page_path) { admin_runner_path(instance_runner) }
|
||||
end
|
||||
|
||||
it_behaves_like 'pauses, resumes and deletes a runner' do
|
||||
let(:runner) { instance_runner }
|
||||
end
|
||||
|
||||
it 'shows an instance badge' do
|
||||
within_runner_row(instance_runner.id) do
|
||||
expect(page).to have_selector '.badge', text: s_('Runners|Instance')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with multiple runners" do
|
||||
before do
|
||||
create(:ci_runner, :instance, :almost_offline)
|
||||
create(:ci_runner, :instance, :offline)
|
||||
create(:ci_runner, :instance, :stale)
|
||||
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'has all necessary texts' do
|
||||
expect(page).to have_text "#{s_('Runners|All')} 3"
|
||||
expect(page).to have_text "#{s_('Runners|Online')} 1"
|
||||
expect(page).to have_text "#{s_('Runners|Offline')} 2"
|
||||
expect(page).to have_text "#{s_('Runners|Stale')} 1"
|
||||
end
|
||||
|
||||
it_behaves_like 'deletes runners in bulk' do
|
||||
let(:runner_count) { '3' }
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows a job count' do
|
||||
runner = create(:ci_runner, :project, projects: [project])
|
||||
create_list(:ci_build, 2, runner: runner)
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_runner_row(runner.id) do
|
||||
expect(find_by_testid('job-count')).to have_content '2'
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows an Active status badge that links to jobs tab' do
|
||||
runner = create(:ci_runner, :project, projects: [project])
|
||||
job = create(:ci_build, :running, runner: runner)
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_runner_row(runner.id) do
|
||||
click_on(s_('Runners|Active'))
|
||||
end
|
||||
|
||||
expect(current_url).to match(admin_runner_path(runner))
|
||||
|
||||
expect(find_by_testid('td-status')).to have_content "Running"
|
||||
expect(find_by_testid('td-job')).to have_content "##{job.id}"
|
||||
end
|
||||
|
||||
describe 'search' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner foo')
|
||||
create(:ci_runner, :instance, description: 'runner bar')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'runner types tabs have total counts and can be selected' do
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Instance 2')
|
||||
expect(page).to have_link('Group 0')
|
||||
expect(page).to have_link('Project 0')
|
||||
end
|
||||
|
||||
it 'shows runners' do
|
||||
expect(page).to have_content("runner foo")
|
||||
expect(page).to have_content("runner bar")
|
||||
end
|
||||
|
||||
it 'shows correct runner when description matches' do
|
||||
input_filtered_search_keys('runner foo')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
expect(page).to have_link('Instance 1')
|
||||
|
||||
expect(page).to have_content("runner foo")
|
||||
expect(page).not_to have_content("runner bar")
|
||||
end
|
||||
|
||||
context 'when description does not match' do
|
||||
before do
|
||||
input_filtered_search_keys('runner baz')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows no runner' do
|
||||
expect(page).to have_link('All 0')
|
||||
expect(page).to have_link('Instance 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by paused' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner-active')
|
||||
create(:ci_runner, :instance, :paused, description: 'runner-paused')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-active'
|
||||
expect(page).to have_content 'runner-paused'
|
||||
end
|
||||
|
||||
it 'shows paused runners' do
|
||||
input_filtered_search_filter_is_only(s_('Runners|Paused'), 'Yes')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-active'
|
||||
expect(page).to have_content 'runner-paused'
|
||||
end
|
||||
|
||||
it 'shows active runners' do
|
||||
input_filtered_search_filter_is_only(s_('Runners|Paused'), 'No')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-active'
|
||||
expect(page).not_to have_content 'runner-paused'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by version prefix' do
|
||||
before_all do
|
||||
runner_v15 = create(:ci_runner, :instance, description: 'runner-v15')
|
||||
runner_v14 = create(:ci_runner, :instance, description: 'runner-v14')
|
||||
|
||||
create(:ci_runner_machine, runner: runner_v15, version: '15.0.0')
|
||||
create(:ci_runner_machine, runner: runner_v14, version: '14.0.0')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-v15'
|
||||
expect(page).to have_content 'runner-v14'
|
||||
end
|
||||
|
||||
it 'shows filtered runner based on supplied prefix' do
|
||||
input_filtered_search_filter_is_only(s_('Runners|Version starts with'), '15.0')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-v14'
|
||||
expect(page).to have_content 'runner-v15'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by creator' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner-creator-admin', creator: admin)
|
||||
create(:ci_runner, :instance, description: 'runner-creator-user', creator: user)
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-creator-admin'
|
||||
expect(page).to have_content 'runner-creator-user'
|
||||
end
|
||||
|
||||
it 'shows filtered runner based on creator' do
|
||||
input_filtered_search_filter_is_only(s_('Runners|Creator'), admin.username)
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-creator-admin'
|
||||
expect(page).not_to have_content 'runner-creator-user'
|
||||
end
|
||||
|
||||
it 'shows filtered search suggestions' do
|
||||
open_filtered_search_suggestions(s_('Runners|Creator'))
|
||||
page.within(search_bar_selector) do
|
||||
expect(page).to have_content admin.username
|
||||
expect(page).to have_content user.username
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by status' do
|
||||
before_all do
|
||||
freeze_time # Freeze time before `let_it_be` runs, so that runner statuses are frozen during execution
|
||||
|
||||
create(:ci_runner, :instance, :online, description: 'runner-1')
|
||||
create(:ci_runner, :instance, :almost_offline, description: 'runner-2')
|
||||
create(:ci_runner, :instance, :contacted_within_stale_deadline, description: 'runner-offline')
|
||||
end
|
||||
|
||||
after :all do
|
||||
unfreeze_time
|
||||
end
|
||||
|
||||
let_it_be(:never_contacted) do
|
||||
create(:ci_runner, :instance, :unregistered, description: 'runner-never-contacted')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 4')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).to have_content 'runner-2'
|
||||
expect(page).to have_content 'runner-offline'
|
||||
expect(page).to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status matches' do
|
||||
input_filtered_search_filter_is_only('Status', s_('Runners|Online'))
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-offline'
|
||||
expect(page).not_to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status is selected and search term is entered' do
|
||||
input_filtered_search_filter_is_only('Status', s_('Runners|Online'))
|
||||
input_filtered_search_keys('runner-1')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).not_to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-offline'
|
||||
expect(page).not_to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status filter is entered' do
|
||||
# use the string "Never" to avoid using space and trigger an early selection
|
||||
input_filtered_search_filter_is_only('Status', 'Never')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-1'
|
||||
expect(page).not_to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-paused'
|
||||
expect(page).to have_content 'runner-never-contacted'
|
||||
|
||||
within_runner_row(never_contacted.id) do
|
||||
expect(page).to have_selector '.badge', text: s_('Runners|Never contacted')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status does not match' do
|
||||
before do
|
||||
input_filtered_search_filter_is_only('Status', 'Stale')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows no runner' do
|
||||
expect(page).to have_link('All 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by type' do
|
||||
before_all do
|
||||
create(:ci_runner, :project, description: 'runner-project', projects: [project])
|
||||
create(:ci_runner, :group, description: 'runner-group', groups: [group])
|
||||
end
|
||||
|
||||
it '"All" tab is selected by default' do
|
||||
visit admin_runners_path
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Group 1')
|
||||
expect(page).to have_link('Project 1')
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
expect(page).to have_link('All', class: 'active')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows correct runner when type matches' do
|
||||
visit admin_runners_path
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-group'
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on('Project')
|
||||
|
||||
expect(page).to have_link('Project', class: 'active')
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
end
|
||||
|
||||
it 'show the same counts after selecting another tab' do
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on('Project')
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Group 1')
|
||||
expect(page).to have_link('Project 1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows correct runner when type is selected and search term is entered' do
|
||||
create(:ci_runner, :project, description: 'runner-2-project', projects: [project])
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Project'
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-2-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
|
||||
input_filtered_search_keys('runner-project')
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-2-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
end
|
||||
|
||||
it 'maintains the same filter when switching between runner types' do
|
||||
create(:ci_runner, :project, :paused, description: 'runner-paused-project', projects: [project])
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
input_filtered_search_filter_is_only(s_('Runners|Paused'), 'No')
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-group'
|
||||
expect(page).not_to have_content 'runner-paused-project'
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Project'
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
expect(page).not_to have_content 'runner-paused-project'
|
||||
end
|
||||
|
||||
context 'when type does not match' do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Instance'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows active tab' do
|
||||
expect(page).to have_link('Instance', class: 'active')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by tag' do
|
||||
let_it_be(:runner_1) { create(:ci_runner, :instance, description: 'runner-blue', tag_list: ['blue']) }
|
||||
let_it_be(:runner_2) { create(:ci_runner, :instance, description: 'runner-2-blue', tag_list: ['blue']) }
|
||||
let_it_be(:runner_3) { create(:ci_runner, :instance, description: 'runner-red', tag_list: ['red']) }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows tags suggestions' do
|
||||
open_filtered_search_suggestions('Tags')
|
||||
|
||||
page.within(search_bar_selector) do
|
||||
expect(page).to have_content 'blue'
|
||||
expect(page).to have_content 'red'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'filters by tag' do
|
||||
let(:tag) { 'blue' }
|
||||
let(:found_runner) { runner_1.description }
|
||||
let(:missing_runner) { runner_3.description }
|
||||
end
|
||||
|
||||
context 'when tag does not match' do
|
||||
before do
|
||||
input_filtered_search_filter_is_only('Tags', 'green')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
end
|
||||
|
||||
it 'shows correct runner when tag is selected and search term is entered' do
|
||||
input_filtered_search_filter_is_only('Tags', 'blue')
|
||||
|
||||
expect(page).to have_content 'runner-blue'
|
||||
expect(page).to have_content 'runner-2-blue'
|
||||
expect(page).not_to have_content 'runner-red'
|
||||
|
||||
input_filtered_search_keys('runner-2-blue')
|
||||
|
||||
expect(page).to have_content 'runner-2-blue'
|
||||
expect(page).not_to have_content 'runner-blue'
|
||||
expect(page).not_to have_content 'runner-red'
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts by last contact date' do
|
||||
create(:ci_runner, :instance, description: 'runner-1', contacted_at: '2018-07-12')
|
||||
create(:ci_runner, :instance, description: 'runner-2', contacted_at: '2018-07-13')
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-list') do
|
||||
within('tbody tr:nth-child(1)') do
|
||||
expect(page).to have_content 'runner-2'
|
||||
end
|
||||
|
||||
within('tbody tr:nth-child(2)') do
|
||||
expect(page).to have_content 'runner-1'
|
||||
end
|
||||
end
|
||||
|
||||
pajamas_sort_by 'Last contact', from: 'Created date'
|
||||
click_on 'Sort direction: Descending'
|
||||
|
||||
within_testid('runner-list') do
|
||||
within('tbody tr:nth-child(1)') do
|
||||
expect(page).to have_content 'runner-1'
|
||||
end
|
||||
|
||||
within('tbody tr:nth-child(2)') do
|
||||
expect(page).to have_content 'runner-2'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are no runners" do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners registered'
|
||||
|
||||
it 'shows tabs with total counts equal to 0' do
|
||||
aggregate_failures do
|
||||
expect(page).to have_link('All 0')
|
||||
expect(page).to have_link('Instance 0')
|
||||
expect(page).to have_link('Group 0')
|
||||
expect(page).to have_link('Project 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when visiting outdated URLs" do
|
||||
it 'updates ACTIVE runner status to paused=false' do
|
||||
visit admin_runners_path('status[]': 'ACTIVE')
|
||||
|
||||
expect(page).to have_current_path(admin_runners_path('paused[]': 'false'))
|
||||
end
|
||||
|
||||
it 'updates PAUSED runner status to paused=true' do
|
||||
visit admin_runners_path('status[]': 'PAUSED')
|
||||
|
||||
expect(page).to have_current_path(admin_runners_path('paused[]': 'true'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Runner create page", :js do
|
||||
before do
|
||||
visit new_admin_runner_path
|
||||
end
|
||||
|
||||
it_behaves_like 'creates runner and shows register page' do
|
||||
let(:register_path_pattern) { register_admin_runner_path('.*') }
|
||||
end
|
||||
end
|
||||
|
||||
describe "Runner show page", :js do
|
||||
let_it_be(:runner) { create(:ci_runner, description: 'runner-foo', tag_list: ['tag1']) }
|
||||
let_it_be(:runner_job) { create(:ci_build, runner: runner) }
|
||||
|
||||
before do
|
||||
visit admin_runner_path(runner)
|
||||
end
|
||||
|
||||
describe 'runner show page breadcrumbs' do
|
||||
it 'contains the current runner id and token' do
|
||||
within_testid('breadcrumb-links') do
|
||||
expect(find('li:last-of-type')).to have_link("##{runner.id} (#{runner.short_sha})")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows runner details' do
|
||||
aggregate_failures do
|
||||
expect(page).to have_content 'Description runner-foo'
|
||||
expect(page).to have_content 'Last contact Never contacted'
|
||||
expect(page).to have_content 'Configuration Runs untagged jobs'
|
||||
expect(page).to have_content 'Maximum job timeout None'
|
||||
expect(page).to have_content 'Tags tag1'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'shows runner jobs tab' do
|
||||
let(:job_count) { '1' }
|
||||
let(:job) { runner_job }
|
||||
end
|
||||
|
||||
describe 'when a runner is deleted' do
|
||||
before do
|
||||
click_on 'Delete runner'
|
||||
|
||||
within_modal do
|
||||
click_on 'Permanently delete runner'
|
||||
end
|
||||
end
|
||||
|
||||
it 'deletes runner and redirects to runner list' do
|
||||
expect(find_by_testid('alert-success')).to have_content('deleted')
|
||||
expect(current_url).to match(admin_runners_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Runner edit page", :js do
|
||||
let_it_be(:project1) { create(:project) }
|
||||
let_it_be(:project2) { create(:project, organization: project1.organization) }
|
||||
let_it_be(:project_runner) { create(:ci_runner, :project, :unregistered, projects: [create(:project)]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(project_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'submits edit runner form' do
|
||||
let(:runner) { project_runner }
|
||||
let(:runner_page_path) { admin_runner_path(project_runner) }
|
||||
end
|
||||
|
||||
it_behaves_like 'shows locked field'
|
||||
|
||||
describe 'breadcrumbs' do
|
||||
it 'contains the current runner id and token' do
|
||||
within_testid('breadcrumb-links') do
|
||||
expect(page).to have_link("##{project_runner.id} (#{project_runner.short_sha})")
|
||||
expect(find('li:last-of-type')).to have_content("Edit")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'runner header', :js do
|
||||
it 'contains the runner status, type and id' do
|
||||
expect(page).to have_content(
|
||||
"##{project_runner.id} (#{project_runner.short_sha}) #{s_('Runners|Never contacted')} Project Created"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a runner is updated', :js do
|
||||
before do
|
||||
click_on _('Save changes')
|
||||
end
|
||||
|
||||
it 'show success alert and redirects to runner page' do
|
||||
expect(current_url).to match(admin_runner_path(project_runner))
|
||||
expect(find_by_testid('alert-success')).to have_content('saved')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'projects' do
|
||||
it 'contains project names' do
|
||||
expect(page).to have_content(project1.full_name)
|
||||
expect(page).to have_content(project2.full_name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'search' do
|
||||
before do
|
||||
search_form = find('#runner-projects-search')
|
||||
search_form.fill_in 'search', with: project1.name
|
||||
search_form.click_button 'Search'
|
||||
end
|
||||
|
||||
it 'contains name of correct project' do
|
||||
expect(page).to have_content(project1.full_name)
|
||||
expect(page).not_to have_content(project2.full_name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enable/create' do
|
||||
shared_examples 'assignable runner' do
|
||||
it 'enables a runner for a project' do
|
||||
within_testid('unassigned-projects') do
|
||||
within('tr', text: project2.full_name) do
|
||||
click_on 'Enable'
|
||||
end
|
||||
end
|
||||
|
||||
assigned_project = find_by_testid('assigned-projects')
|
||||
|
||||
expect(page).to have_content('Runner assigned to project.')
|
||||
expect(assigned_project).to have_content(project2.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with project runner' do
|
||||
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project1]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(project_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'assignable runner'
|
||||
end
|
||||
|
||||
context 'with locked runner' do
|
||||
let_it_be(:locked_runner) { create(:ci_runner, :project, projects: [project1], locked: true) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(locked_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'assignable runner'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'disable/destroy' do
|
||||
let_it_be(:runner) { create(:ci_runner, :project, projects: [project1]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(runner)
|
||||
end
|
||||
|
||||
it 'removed project runner from project' do
|
||||
within_testid('assigned-projects') do
|
||||
click_on 'Disable'
|
||||
end
|
||||
|
||||
new_runner_project = find_by_testid('unassigned-projects')
|
||||
|
||||
expect(page).to have_content('Runner unassigned from project.')
|
||||
expect(new_runner_project).to have_content(project1.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "Admin creates an instance runner", :js, feature_category: :fleet_visibility do
|
||||
let_it_be(:admin) { create(:admin) }
|
||||
|
||||
before do
|
||||
sign_in(admin)
|
||||
enable_admin_mode!(admin)
|
||||
end
|
||||
|
||||
describe "admin runners page" do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'displays a create button' do
|
||||
expect(page).to have_link s_('Runner|New instance runner'), href: new_admin_runner_path
|
||||
end
|
||||
|
||||
it_behaves_like "shows and resets runner registration token" do
|
||||
let(:dropdown_text) { s_('Runners|Register an instance runner') }
|
||||
let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
|
||||
end
|
||||
end
|
||||
|
||||
describe "create runner" do
|
||||
before do
|
||||
visit new_admin_runner_path
|
||||
end
|
||||
|
||||
it_behaves_like 'creates runner and shows register page' do
|
||||
let(:register_path_pattern) { register_admin_runner_path('.*') }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "Admin manages runner in admin section", :js, feature_category: :fleet_visibility do
|
||||
include Spec::Support::Helpers::ModalHelpers
|
||||
|
||||
let_it_be(:admin) { create(:admin) }
|
||||
|
||||
before do
|
||||
sign_in(admin)
|
||||
enable_admin_mode!(admin)
|
||||
end
|
||||
|
||||
describe 'shows runner' do
|
||||
let_it_be(:runner) { create(:ci_runner, description: 'runner-foo', tag_list: ['tag1']) }
|
||||
let_it_be(:runner_job) { create(:ci_build, runner: runner) }
|
||||
|
||||
before do
|
||||
visit admin_runner_path(runner)
|
||||
end
|
||||
|
||||
describe 'runner show page breadcrumbs' do
|
||||
it 'contains the current runner id and token' do
|
||||
within_testid('breadcrumb-links') do
|
||||
expect(find('li:last-of-type')).to have_link("##{runner.id} (#{runner.short_sha})")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows runner details' do
|
||||
aggregate_failures do
|
||||
expect(page).to have_content 'Description runner-foo'
|
||||
expect(page).to have_content 'Last contact Never contacted'
|
||||
expect(page).to have_content 'Configuration Runs untagged jobs'
|
||||
expect(page).to have_content 'Maximum job timeout None'
|
||||
expect(page).to have_content 'Tags tag1'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'shows runner jobs tab' do
|
||||
let(:job_count) { '1' }
|
||||
let(:job) { runner_job }
|
||||
end
|
||||
|
||||
describe 'when a runner is deleted' do
|
||||
before do
|
||||
click_on 'Delete runner'
|
||||
|
||||
within_modal do
|
||||
click_on 'Permanently delete runner'
|
||||
end
|
||||
end
|
||||
|
||||
it 'deletes runner and redirects to runner list' do
|
||||
expect(find_by_testid('alert-success')).to have_content('deleted')
|
||||
expect(current_url).to match(admin_runners_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "edits runner" do
|
||||
let_it_be(:project1) { create(:project) }
|
||||
let_it_be(:project2) { create(:project, organization: project1.organization) }
|
||||
let_it_be(:project_runner) { create(:ci_runner, :project, :unregistered, projects: [create(:project)]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(project_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'submits edit runner form' do
|
||||
let(:runner) { project_runner }
|
||||
let(:runner_page_path) { admin_runner_path(project_runner) }
|
||||
end
|
||||
|
||||
it_behaves_like 'shows locked field'
|
||||
|
||||
describe 'breadcrumbs' do
|
||||
it 'contains the current runner id and token' do
|
||||
within_testid('breadcrumb-links') do
|
||||
expect(page).to have_link("##{project_runner.id} (#{project_runner.short_sha})")
|
||||
expect(find('li:last-of-type')).to have_content("Edit")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'runner header' do
|
||||
it 'contains the runner status, type and id' do
|
||||
expect(page).to have_content(
|
||||
"##{project_runner.id} (#{project_runner.short_sha}) #{s_('Runners|Never contacted')} Project Created"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a runner is updated' do
|
||||
before do
|
||||
click_on _('Save changes')
|
||||
end
|
||||
|
||||
it 'show success alert and redirects to runner page' do
|
||||
expect(current_url).to match(admin_runner_path(project_runner))
|
||||
expect(find_by_testid('alert-success')).to have_content('saved')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'projects' do
|
||||
it 'contains project names' do
|
||||
expect(page).to have_content(project1.full_name)
|
||||
expect(page).to have_content(project2.full_name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'search' do
|
||||
before do
|
||||
search_form = find('#runner-projects-search')
|
||||
search_form.fill_in 'search', with: project1.name
|
||||
search_form.click_button 'Search'
|
||||
end
|
||||
|
||||
it 'contains name of correct project' do
|
||||
expect(page).to have_content(project1.full_name)
|
||||
expect(page).not_to have_content(project2.full_name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enable/create' do
|
||||
shared_examples 'assignable runner' do
|
||||
it 'enables a runner for a project' do
|
||||
within_testid('unassigned-projects') do
|
||||
within('tr', text: project2.full_name) do
|
||||
click_on 'Enable'
|
||||
end
|
||||
end
|
||||
|
||||
assigned_project = find_by_testid('assigned-projects')
|
||||
|
||||
expect(page).to have_content('Runner assigned to project.')
|
||||
expect(assigned_project).to have_content(project2.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with project runner' do
|
||||
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project1]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(project_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'assignable runner'
|
||||
end
|
||||
|
||||
context 'with locked runner' do
|
||||
let_it_be(:locked_runner) { create(:ci_runner, :project, projects: [project1], locked: true) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(locked_runner)
|
||||
end
|
||||
|
||||
it_behaves_like 'assignable runner'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'disable/destroy' do
|
||||
let_it_be(:runner) { create(:ci_runner, :project, projects: [project1]) }
|
||||
|
||||
before do
|
||||
visit edit_admin_runner_path(runner)
|
||||
end
|
||||
|
||||
it 'removed project runner from project' do
|
||||
within_testid('assigned-projects') do
|
||||
click_on 'Disable'
|
||||
end
|
||||
|
||||
new_runner_project = find_by_testid('unassigned-projects')
|
||||
|
||||
expect(page).to have_content('Runner unassigned from project.')
|
||||
expect(new_runner_project).to have_content(project1.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "Admin manages runners in admin runner list", :freeze_time, :js, feature_category: :fleet_visibility do
|
||||
include Features::SortingHelpers
|
||||
include Features::RunnersHelpers
|
||||
include Spec::Support::Helpers::ModalHelpers
|
||||
|
||||
let_it_be(:admin) { create(:admin) }
|
||||
|
||||
before do
|
||||
sign_in(admin)
|
||||
enable_admin_mode!(admin)
|
||||
end
|
||||
|
||||
context "with runners" do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:namespace) { create(:namespace) }
|
||||
let_it_be(:project) { create(:project, namespace: namespace, creator: user) }
|
||||
|
||||
context "with an instance runner" do
|
||||
let_it_be(:instance_runner) { create(:ci_runner, :instance) }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it_behaves_like 'shows runner summary and navigates to details' do
|
||||
let(:runner) { instance_runner }
|
||||
let(:runner_page_path) { admin_runner_path(instance_runner) }
|
||||
end
|
||||
|
||||
it_behaves_like 'pauses, resumes and deletes a runner' do
|
||||
let(:runner) { instance_runner }
|
||||
end
|
||||
|
||||
it 'shows an instance badge' do
|
||||
within_runner_row(instance_runner.id) do
|
||||
expect(page).to have_selector '.badge', text: 'Instance'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with multiple runners" do
|
||||
before do
|
||||
create(:ci_runner, :instance, :almost_offline)
|
||||
create(:ci_runner, :instance, :offline)
|
||||
create(:ci_runner, :instance, :stale)
|
||||
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'has all necessary texts' do
|
||||
expect(page).to have_text "All 3"
|
||||
expect(page).to have_text "Online 1"
|
||||
expect(page).to have_text "Offline 2"
|
||||
expect(page).to have_text "Stale 1"
|
||||
end
|
||||
|
||||
it_behaves_like 'deletes runners in bulk' do
|
||||
let(:runner_count) { '3' }
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows a job count' do
|
||||
runner = create(:ci_runner, :project, projects: [project])
|
||||
create_list(:ci_build, 2, runner: runner)
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_runner_row(runner.id) do
|
||||
expect(find_by_testid('job-count')).to have_content '2'
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows an Active status badge that links to jobs tab' do
|
||||
runner = create(:ci_runner, :project, projects: [project])
|
||||
job = create(:ci_build, :running, runner: runner)
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_runner_row(runner.id) do
|
||||
click_on('Active')
|
||||
end
|
||||
|
||||
expect(current_url).to match(admin_runner_path(runner))
|
||||
|
||||
expect(find_by_testid('td-status')).to have_content "Running"
|
||||
expect(find_by_testid('td-job')).to have_content "##{job.id}"
|
||||
end
|
||||
|
||||
describe 'searches for a runner' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner foo')
|
||||
create(:ci_runner, :instance, description: 'runner bar')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'runner types tabs have total counts and can be selected' do
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Instance 2')
|
||||
expect(page).to have_link('Group 0')
|
||||
expect(page).to have_link('Project 0')
|
||||
end
|
||||
|
||||
it 'shows runners' do
|
||||
expect(page).to have_content("runner foo")
|
||||
expect(page).to have_content("runner bar")
|
||||
end
|
||||
|
||||
it 'shows correct runner when description matches' do
|
||||
input_filtered_search_keys('runner foo')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
expect(page).to have_link('Instance 1')
|
||||
|
||||
expect(page).to have_content("runner foo")
|
||||
expect(page).not_to have_content("runner bar")
|
||||
end
|
||||
|
||||
context 'when description does not match' do
|
||||
before do
|
||||
input_filtered_search_keys('runner baz')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows no runner' do
|
||||
expect(page).to have_link('All 0')
|
||||
expect(page).to have_link('Instance 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by paused' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner-active')
|
||||
create(:ci_runner, :instance, :paused, description: 'runner-paused')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-active'
|
||||
expect(page).to have_content 'runner-paused'
|
||||
end
|
||||
|
||||
it 'shows paused runners' do
|
||||
input_filtered_search_filter_is_only('Paused', 'Yes')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-active'
|
||||
expect(page).to have_content 'runner-paused'
|
||||
end
|
||||
|
||||
it 'shows active runners' do
|
||||
input_filtered_search_filter_is_only('Paused', 'No')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-active'
|
||||
expect(page).not_to have_content 'runner-paused'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by version prefix' do
|
||||
before_all do
|
||||
runner_v15 = create(:ci_runner, :instance, description: 'runner-v15')
|
||||
runner_v14 = create(:ci_runner, :instance, description: 'runner-v14')
|
||||
|
||||
create(:ci_runner_machine, runner: runner_v15, version: '15.0.0')
|
||||
create(:ci_runner_machine, runner: runner_v14, version: '14.0.0')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-v15'
|
||||
expect(page).to have_content 'runner-v14'
|
||||
end
|
||||
|
||||
it 'shows filtered runner based on supplied prefix' do
|
||||
input_filtered_search_filter_is_only('Version starts with', '15.0')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-v14'
|
||||
expect(page).to have_content 'runner-v15'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by creator' do
|
||||
before_all do
|
||||
create(:ci_runner, :instance, description: 'runner-creator-admin', creator: admin)
|
||||
create(:ci_runner, :instance, description: 'runner-creator-user', creator: user)
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-creator-admin'
|
||||
expect(page).to have_content 'runner-creator-user'
|
||||
end
|
||||
|
||||
it 'shows filtered runner based on creator' do
|
||||
input_filtered_search_filter_is_only('Creator', admin.username)
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-creator-admin'
|
||||
expect(page).not_to have_content 'runner-creator-user'
|
||||
end
|
||||
|
||||
it 'shows filtered search suggestions' do
|
||||
open_filtered_search_suggestions('Creator')
|
||||
page.within(search_bar_selector) do
|
||||
expect(page).to have_content admin.username
|
||||
expect(page).to have_content user.username
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by status' do
|
||||
before_all do
|
||||
freeze_time # Freeze time before `let_it_be` runs, so that runner statuses are frozen during execution
|
||||
|
||||
create(:ci_runner, :instance, :online, description: 'runner-1')
|
||||
create(:ci_runner, :instance, :almost_offline, description: 'runner-2')
|
||||
create(:ci_runner, :instance, :contacted_within_stale_deadline, description: 'runner-offline')
|
||||
end
|
||||
|
||||
after :all do
|
||||
unfreeze_time
|
||||
end
|
||||
|
||||
let_it_be(:never_contacted) do
|
||||
create(:ci_runner, :instance, :unregistered, description: 'runner-never-contacted')
|
||||
end
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows all runners' do
|
||||
expect(page).to have_link('All 4')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).to have_content 'runner-2'
|
||||
expect(page).to have_content 'runner-offline'
|
||||
expect(page).to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status matches' do
|
||||
input_filtered_search_filter_is_only('Status', 'Online')
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-offline'
|
||||
expect(page).not_to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status is selected and search term is entered' do
|
||||
input_filtered_search_filter_is_only('Status', 'Online')
|
||||
input_filtered_search_keys('runner-1')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).to have_content 'runner-1'
|
||||
expect(page).not_to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-offline'
|
||||
expect(page).not_to have_content 'runner-never-contacted'
|
||||
end
|
||||
|
||||
it 'shows correct runner when status filter is entered' do
|
||||
# use the string "Never" to avoid using space and trigger an early selection
|
||||
input_filtered_search_filter_is_only('Status', 'Never')
|
||||
|
||||
expect(page).to have_link('All 1')
|
||||
|
||||
expect(page).not_to have_content 'runner-1'
|
||||
expect(page).not_to have_content 'runner-2'
|
||||
expect(page).not_to have_content 'runner-paused'
|
||||
expect(page).to have_content 'runner-never-contacted'
|
||||
|
||||
within_runner_row(never_contacted.id) do
|
||||
expect(page).to have_selector '.badge', text: 'Never contacted'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status does not match' do
|
||||
before do
|
||||
input_filtered_search_filter_is_only('Status', 'Stale')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows no runner' do
|
||||
expect(page).to have_link('All 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by type' do
|
||||
before_all do
|
||||
create(:ci_runner, :project, description: 'runner-project', projects: [project])
|
||||
create(:ci_runner, :group, description: 'runner-group', groups: [group])
|
||||
end
|
||||
|
||||
it '"All" tab is selected by default' do
|
||||
visit admin_runners_path
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Group 1')
|
||||
expect(page).to have_link('Project 1')
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
expect(page).to have_link('All', class: 'active')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows correct runner when type matches' do
|
||||
visit admin_runners_path
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-group'
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on('Project')
|
||||
|
||||
expect(page).to have_link('Project', class: 'active')
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
end
|
||||
|
||||
it 'show the same counts after selecting another tab' do
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on('Project')
|
||||
|
||||
expect(page).to have_link('All 2')
|
||||
expect(page).to have_link('Group 1')
|
||||
expect(page).to have_link('Project 1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows correct runner when type is selected and search term is entered' do
|
||||
create(:ci_runner, :project, description: 'runner-2-project', projects: [project])
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Project'
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-2-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
|
||||
input_filtered_search_keys('runner-project')
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-2-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
end
|
||||
|
||||
it 'maintains the same filter when switching between runner types' do
|
||||
create(:ci_runner, :project, :paused, description: 'runner-paused-project', projects: [project])
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
input_filtered_search_filter_is_only('Paused', 'No')
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).to have_content 'runner-group'
|
||||
expect(page).not_to have_content 'runner-paused-project'
|
||||
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Project'
|
||||
end
|
||||
|
||||
expect(page).to have_content 'runner-project'
|
||||
expect(page).not_to have_content 'runner-group'
|
||||
expect(page).not_to have_content 'runner-paused-project'
|
||||
end
|
||||
|
||||
context 'when type does not match' do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
within_testid('runner-type-tabs') do
|
||||
click_on 'Instance'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
|
||||
it 'shows active tab' do
|
||||
expect(page).to have_link('Instance', class: 'active')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'filter by tag' do
|
||||
let_it_be(:runner_1) { create(:ci_runner, :instance, description: 'runner-blue', tag_list: ['blue']) }
|
||||
let_it_be(:runner_2) { create(:ci_runner, :instance, description: 'runner-2-blue', tag_list: ['blue']) }
|
||||
let_it_be(:runner_3) { create(:ci_runner, :instance, description: 'runner-red', tag_list: ['red']) }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'shows tags suggestions' do
|
||||
open_filtered_search_suggestions('Tags')
|
||||
|
||||
page.within(search_bar_selector) do
|
||||
expect(page).to have_content 'blue'
|
||||
expect(page).to have_content 'red'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'filters by tag' do
|
||||
let(:tag) { 'blue' }
|
||||
let(:found_runner) { runner_1.description }
|
||||
let(:missing_runner) { runner_3.description }
|
||||
end
|
||||
|
||||
context 'when tag does not match' do
|
||||
before do
|
||||
input_filtered_search_filter_is_only('Tags', 'green')
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners found'
|
||||
end
|
||||
|
||||
it 'shows correct runner when tag is selected and search term is entered' do
|
||||
input_filtered_search_filter_is_only('Tags', 'blue')
|
||||
|
||||
expect(page).to have_content 'runner-blue'
|
||||
expect(page).to have_content 'runner-2-blue'
|
||||
expect(page).not_to have_content 'runner-red'
|
||||
|
||||
input_filtered_search_keys('runner-2-blue')
|
||||
|
||||
expect(page).to have_content 'runner-2-blue'
|
||||
expect(page).not_to have_content 'runner-blue'
|
||||
expect(page).not_to have_content 'runner-red'
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts by last contact date' do
|
||||
create(:ci_runner, :instance, description: 'runner-1', contacted_at: '2018-07-12')
|
||||
create(:ci_runner, :instance, description: 'runner-2', contacted_at: '2018-07-13')
|
||||
|
||||
visit admin_runners_path
|
||||
|
||||
within_testid('runner-list') do
|
||||
within('tbody tr:nth-child(1)') do
|
||||
expect(page).to have_content 'runner-2'
|
||||
end
|
||||
|
||||
within('tbody tr:nth-child(2)') do
|
||||
expect(page).to have_content 'runner-1'
|
||||
end
|
||||
end
|
||||
|
||||
pajamas_sort_by 'Last contact', from: 'Created date'
|
||||
click_on 'Sort direction: Descending'
|
||||
|
||||
within_testid('runner-list') do
|
||||
within('tbody tr:nth-child(1)') do
|
||||
expect(page).to have_content 'runner-1'
|
||||
end
|
||||
|
||||
within('tbody tr:nth-child(2)') do
|
||||
expect(page).to have_content 'runner-2'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with no runners" do
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it_behaves_like 'shows no runners registered'
|
||||
|
||||
it 'shows tabs with total counts equal to 0' do
|
||||
aggregate_failures do
|
||||
expect(page).to have_link('All 0')
|
||||
expect(page).to have_link('Instance 0')
|
||||
expect(page).to have_link('Group 0')
|
||||
expect(page).to have_link('Project 0')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when visiting outdated URLs" do
|
||||
it 'updates ACTIVE runner status to paused=false' do
|
||||
visit admin_runners_path('status[]': 'ACTIVE')
|
||||
|
||||
expect(page).to have_current_path(admin_runners_path('paused[]': 'false'))
|
||||
end
|
||||
|
||||
it 'updates PAUSED runner status to paused=true' do
|
||||
visit admin_runners_path('status[]': 'PAUSED')
|
||||
|
||||
expect(page).to have_current_path(admin_runners_path('paused[]': 'true'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -365,7 +365,7 @@ RSpec.describe 'Edit group settings', feature_category: :groups_and_projects do
|
|||
end
|
||||
|
||||
expect(page).to have_selector(selector, visible: :visible)
|
||||
expect(page).to have_content _('Please enter a value of 90 days or more')
|
||||
expect(page).to have_content _('Please enter a value between 90 and 1827 days (5 years)')
|
||||
end
|
||||
|
||||
it 'auto disables dormant members period field depending on parent checkbox', :js do
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ describe('GroupItem spec', () => {
|
|||
|
||||
const MOCK_GROUP = { id: 123, fullName: 'Group 1', name: 'group1', avatarUrl: 'some/avatar.jpg' };
|
||||
|
||||
const createComponent = (props) => {
|
||||
const createComponent = (props, options) => {
|
||||
wrapper = mountExtended(GroupItem, {
|
||||
propsData: {
|
||||
data: MOCK_GROUP,
|
||||
...props,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -24,10 +25,8 @@ describe('GroupItem spec', () => {
|
|||
|
||||
it('renders an Avatar component', () => {
|
||||
expect(findAvatar().props('size')).toBe(32);
|
||||
expect(findAvatar().attributes()).toMatchObject({
|
||||
src: MOCK_GROUP.avatarUrl,
|
||||
alt: MOCK_GROUP.fullName,
|
||||
});
|
||||
expect(findAvatar().props('src')).toBe(MOCK_GROUP.avatarUrl);
|
||||
expect(findAvatar().attributes('alt')).toBe(MOCK_GROUP.fullName);
|
||||
});
|
||||
|
||||
it('renders a fullName and name', () => {
|
||||
|
|
@ -40,7 +39,12 @@ describe('GroupItem spec', () => {
|
|||
});
|
||||
|
||||
describe('hidden groups', () => {
|
||||
beforeEach(() => createComponent({ data: { ...MOCK_GROUP, type: 'hidden_groups' } }));
|
||||
beforeEach(() =>
|
||||
createComponent(
|
||||
{ data: { ...MOCK_GROUP, type: 'hidden_groups' } },
|
||||
{ stubs: { HiddenGroupsItem } },
|
||||
),
|
||||
);
|
||||
|
||||
const findHiddenGroupsItem = () => wrapper.findComponent(HiddenGroupsItem);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,8 @@ describe('UserItem spec', () => {
|
|||
|
||||
it('renders an Avatar component', () => {
|
||||
expect(findAvatar().props('size')).toBe(32);
|
||||
expect(findAvatar().attributes()).toMatchObject({
|
||||
src: MOCK_USER.avatarUrl,
|
||||
alt: MOCK_USER.name,
|
||||
});
|
||||
expect(findAvatar().props('src')).toBe(MOCK_USER.avatarUrl);
|
||||
expect(findAvatar().attributes('alt')).toBe(MOCK_USER.name);
|
||||
});
|
||||
|
||||
it('renders a name and username', () => {
|
||||
|
|
|
|||
|
|
@ -1,430 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy, feature_category: :database do
|
||||
let(:connection) { ActiveRecord::Base.connection }
|
||||
|
||||
describe '#current_partitions' do
|
||||
subject { described_class.new(model, partitioning_key).current_partitions }
|
||||
|
||||
let(:model) { double('model', table_name: table_name) }
|
||||
let(:partitioning_key) { double }
|
||||
let(:table_name) { :_test_partitioned_test }
|
||||
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-05-01') TO ('2020-06-01');
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'detects both partitions' do
|
||||
expect(subject).to eq(
|
||||
[
|
||||
Gitlab::Database::Partitioning::TimePartition.new(table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000'),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(table_name, '2020-05-01', '2020-06-01', partition_name: '_test_partitioned_test_202005')
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#missing_partitions' do
|
||||
subject { described_class.new(model, partitioning_key).missing_partitions }
|
||||
|
||||
let(:model) do
|
||||
Class.new(ActiveRecord::Base) do
|
||||
self.table_name = '_test_partitioned_test'
|
||||
self.primary_key = :id
|
||||
end
|
||||
end
|
||||
|
||||
let(:partitioning_key) { :created_at }
|
||||
|
||||
around do |example|
|
||||
travel_to(Date.parse('2020-08-22')) { example.run }
|
||||
end
|
||||
|
||||
context 'with existing partitions' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01');
|
||||
SQL
|
||||
|
||||
# Insert some data, it doesn't make a difference
|
||||
model.create!(created_at: Date.parse('2020-04-20'))
|
||||
model.create!(created_at: Date.parse('2020-06-15'))
|
||||
end
|
||||
|
||||
context 'when pruning partitions before June 2020' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 1.month).missing_partitions }
|
||||
|
||||
it 'does not include the missing partition from May 2020 because it would be dropped' do
|
||||
expect(subject).not_to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: "#{model.table_name}_202005"))
|
||||
end
|
||||
|
||||
it 'detects the missing partition for 1 month ago (July 2020)' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-07-01', '2020-08-01', partition_name: "#{model.table_name}_202007"))
|
||||
end
|
||||
end
|
||||
|
||||
it 'detects the gap and the missing partition in May 2020' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: "#{model.table_name}_202005"))
|
||||
end
|
||||
|
||||
it 'detects the missing partitions at the end of the range and expects a partition for July 2020' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-07-01', '2020-08-01', partition_name: "#{model.table_name}_202007"))
|
||||
end
|
||||
|
||||
it 'detects the missing partitions at the end of the range and expects a partition for August 2020' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-08-01', '2020-09-01', partition_name: "#{model.table_name}_202008"))
|
||||
end
|
||||
|
||||
it 'creates partitions 6 months out from now (Sep 2020 through Feb 2021)' do
|
||||
expect(subject).to include(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-09-01', '2020-10-01', partition_name: "#{model.table_name}_202009"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-10-01', '2020-11-01', partition_name: "#{model.table_name}_202010"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-11-01', '2020-12-01', partition_name: "#{model.table_name}_202011"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-12-01', '2021-01-01', partition_name: "#{model.table_name}_202012"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2021-01-01', '2021-02-01', partition_name: "#{model.table_name}_202101"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2021-02-01', '2021-03-01', partition_name: "#{model.table_name}_202102")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects all missing partitions' do
|
||||
expect(subject.size).to eq(9)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without existing partitions' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
SQL
|
||||
end
|
||||
|
||||
context 'when pruning partitions before June 2020' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 1.month).missing_partitions }
|
||||
|
||||
it 'detects exactly the set of partitions from June 2020 to March 2021' do
|
||||
months = %w[2020-07-01 2020-08-01 2020-09-01 2020-10-01 2020-11-01 2020-12-01 2021-01-01 2021-02-01 2021-03-01]
|
||||
expected = months[..-2].zip(months.drop(1)).map do |(from, to)|
|
||||
partition_name = "#{model.table_name}_#{Date.parse(from).strftime('%Y%m')}"
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, from, to, partition_name: partition_name)
|
||||
end
|
||||
|
||||
expect(subject).to match_array(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'detects the missing catch-all partition at the beginning' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-08-01', partition_name: "#{model.table_name}_000000"))
|
||||
end
|
||||
|
||||
it 'detects the missing partition for today and expects a partition for August 2020' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-08-01', '2020-09-01', partition_name: "#{model.table_name}_202008"))
|
||||
end
|
||||
|
||||
it 'creates partitions 6 months out from now (Sep 2020 through Feb 2021' do
|
||||
expect(subject).to include(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-09-01', '2020-10-01', partition_name: "#{model.table_name}_202009"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-10-01', '2020-11-01', partition_name: "#{model.table_name}_202010"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-11-01', '2020-12-01', partition_name: "#{model.table_name}_202011"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-12-01', '2021-01-01', partition_name: "#{model.table_name}_202012"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2021-01-01', '2021-02-01', partition_name: "#{model.table_name}_202101"),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2021-02-01', '2021-03-01', partition_name: "#{model.table_name}_202102")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects all missing partitions' do
|
||||
expect(subject.size).to eq(8)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a regular partition but no catchall (MINVALUE, to) partition' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01');
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'detects a missing catch-all partition to add before the existing partition' do
|
||||
expect(subject).to include(Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-06-01', partition_name: "#{model.table_name}_000000"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extra_partitions' do
|
||||
let(:model) do
|
||||
Class.new(ActiveRecord::Base) do
|
||||
self.table_name = '_test_partitioned_test'
|
||||
self.primary_key = :id
|
||||
end
|
||||
end
|
||||
|
||||
let(:partitioning_key) { :created_at }
|
||||
let(:table_name) { :_test_partitioned_test }
|
||||
|
||||
around do |example|
|
||||
travel_to(Date.parse('2020-08-22')) { example.run }
|
||||
end
|
||||
|
||||
describe 'with existing partitions' do
|
||||
before do
|
||||
ActiveRecord::Base.connection.execute(<<~SQL)
|
||||
CREATE TABLE #{table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-05-01') TO ('2020-06-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01')
|
||||
SQL
|
||||
end
|
||||
|
||||
context 'without a time retention policy' do
|
||||
subject { described_class.new(model, partitioning_key).extra_partitions }
|
||||
|
||||
it 'has no extra partitions to prune' do
|
||||
expect(subject).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy that excludes no partitions' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 4.months).extra_partitions }
|
||||
|
||||
it 'has no extra partitions to prune' do
|
||||
expect(subject).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 3 months' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 3.months).extra_partitions }
|
||||
|
||||
it 'prunes the unbounded partition ending 2020-05-01' do
|
||||
min_value_to_may = Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
nil,
|
||||
'2020-05-01',
|
||||
partition_name: '_test_partitioned_test_000000'
|
||||
)
|
||||
|
||||
expect(subject).to contain_exactly(min_value_to_may)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 2 months' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 2.months).extra_partitions }
|
||||
|
||||
it 'prunes the unbounded partition and the partition for May-June' do
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
nil,
|
||||
'2020-05-01',
|
||||
partition_name: '_test_partitioned_test_000000'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-05-01',
|
||||
'2020-06-01',
|
||||
partition_name: '_test_partitioned_test_202005'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the retain_non_empty_partitions is true' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: 2.months, retain_non_empty_partitions: true).extra_partitions }
|
||||
|
||||
it 'prunes empty partitions' do
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
nil,
|
||||
'2020-05-01',
|
||||
partition_name: '_test_partitioned_test_000000'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-05-01',
|
||||
'2020-06-01',
|
||||
partition_name: '_test_partitioned_test_202005'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not prune non-empty partitions' do
|
||||
connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into _test_partitioned_test_202005
|
||||
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000')
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 1 month' do
|
||||
let(:retention_period) { 1.month }
|
||||
|
||||
subject(:partitions_to_remove) { described_class.new(model, partitioning_key, retain_for: retention_period).extra_partitions }
|
||||
|
||||
it 'prunes the unbounded partition and the partitions for May-June and June-July' do
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
nil,
|
||||
'2020-05-01',
|
||||
partition_name: '_test_partitioned_test_000000'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-05-01',
|
||||
'2020-06-01',
|
||||
partition_name: '_test_partitioned_test_202005'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-06-01',
|
||||
'2020-07-01',
|
||||
partition_name: '_test_partitioned_test_202006'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'contains partitions starting at least one month in the past' do
|
||||
prune_to = partitions_to_remove.map(&:to).max
|
||||
expect(1.month.ago).to be_after(prune_to)
|
||||
|
||||
strategy = described_class.new(model, partitioning_key, retain_for: retention_period)
|
||||
desired_partitions = strategy.current_partitions - strategy.extra_partitions + strategy.missing_partitions
|
||||
# Double check this is equivalent to the private method
|
||||
expect(desired_partitions).to contain_exactly(*strategy.send(:desired_partitions))
|
||||
end
|
||||
|
||||
context 'when the retain_non_empty_partitions is true' do
|
||||
subject { described_class.new(model, partitioning_key, retain_for: retention_period, retain_non_empty_partitions: true).extra_partitions }
|
||||
|
||||
it 'prunes empty partitions' do
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
nil,
|
||||
'2020-05-01',
|
||||
partition_name: '_test_partitioned_test_000000'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-05-01',
|
||||
'2020-06-01',
|
||||
partition_name: '_test_partitioned_test_202005'
|
||||
),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
model.table_name,
|
||||
'2020-06-01',
|
||||
'2020-07-01',
|
||||
partition_name: '_test_partitioned_test_202006'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not prune non-empty partitions' do
|
||||
connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into _test_partitioned_test_202005
|
||||
|
||||
expect(subject).to contain_exactly(
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000'),
|
||||
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-06-01', '2020-07-01', partition_name: '_test_partitioned_test_202006')
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'attributes' do
|
||||
let(:partitioning_key) { :partition }
|
||||
let(:retain_non_empty_partitions) { true }
|
||||
let(:retain_for) { 12.months }
|
||||
let(:analyze_interval) { 1.week }
|
||||
let(:model) { class_double(ApplicationRecord, table_name: table_name, connection: connection) }
|
||||
let(:table_name) { :_test_partitioned_test }
|
||||
|
||||
subject(:strategy) do
|
||||
described_class.new(
|
||||
model, partitioning_key,
|
||||
retain_for: retain_for,
|
||||
retain_non_empty_partitions: retain_non_empty_partitions,
|
||||
analyze_interval: analyze_interval
|
||||
)
|
||||
end
|
||||
|
||||
specify do
|
||||
expect(strategy).to have_attributes({
|
||||
model: model,
|
||||
partitioning_key: partitioning_key,
|
||||
retain_for: retain_for,
|
||||
retain_non_empty_partitions: retain_non_empty_partitions,
|
||||
analyze_interval: analyze_interval
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#partition_name' do
|
||||
let(:model) { double('model', table_name: table_name) }
|
||||
let(:partitioning_key) { double }
|
||||
let(:table_name) { '_test_partitioned_test' }
|
||||
let(:from) { Date.parse('2020-04-01 00:00:00') }
|
||||
let(:to) { Date.parse('2020-05-01 00:00:00') }
|
||||
|
||||
subject(:partition_name) { described_class.new(model, partitioning_key).partition_name(from) }
|
||||
|
||||
it 'uses table_name as prefix' do
|
||||
expect(partition_name).to start_with(table_name)
|
||||
end
|
||||
|
||||
it 'uses Year-Month (from) as suffix' do
|
||||
expect(partition_name).to end_with("_202004")
|
||||
end
|
||||
|
||||
context 'without from date' do
|
||||
let(:from) { nil }
|
||||
|
||||
it 'uses 000000 as suffix for first partition' do
|
||||
expect(partition_name).to end_with("_000000")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -427,7 +427,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager, feature_categor
|
|||
|
||||
describe 'strategies that support analyze_interval' do
|
||||
[
|
||||
::Gitlab::Database::Partitioning::MonthlyStrategy,
|
||||
::Gitlab::Database::Partitioning::Time::MonthlyStrategy,
|
||||
::Gitlab::Database::Partitioning::SlidingListStrategy,
|
||||
::Gitlab::Database::Partitioning::CiSlidingListStrategy
|
||||
].each do |klass|
|
||||
|
|
@ -463,7 +463,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager, feature_categor
|
|||
|
||||
it 'creates partitions for the future then drops the oldest one after a month' do
|
||||
# 1 month for the current month, 1 month for the old month that we're retaining data for, headroom
|
||||
expected_num_partitions = (Gitlab::Database::Partitioning::MonthlyStrategy::HEADROOM + 2.months) / 1.month
|
||||
expected_num_partitions = (Gitlab::Database::Partitioning::Time::MonthlyStrategy::HEADROOM + 2.months) / 1.month
|
||||
expect { described_class.new(my_model).sync_partitions }.to change { num_partitions(my_model) }.from(0).to(expected_num_partitions)
|
||||
|
||||
travel 1.month
|
||||
|
|
|
|||
|
|
@ -0,0 +1,384 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Database::Partitioning::Time::MonthlyStrategy, feature_category: :database do
|
||||
let(:connection) { ApplicationRecord.connection }
|
||||
let(:monthly_strategy) do
|
||||
described_class.new(model, partitioning_key, retain_for: retention_period, retain_non_empty_partitions: retain_data)
|
||||
end
|
||||
|
||||
let(:retention_period) { nil }
|
||||
let(:retain_data) { false }
|
||||
let(:partitioning_key) { :created_at }
|
||||
let(:table_name) { model.table_name }
|
||||
let(:model) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_partitioned_test'
|
||||
self.primary_key = :id
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current_partitions' do
|
||||
subject(:current_partitions) { monthly_strategy.current_partitions }
|
||||
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-05-01') TO ('2020-06-01');
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'detects both partitions' do
|
||||
expect(current_partitions).to eq(
|
||||
[
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005")
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#missing_partitions', time_travel_to: '2020-08-22' do
|
||||
subject(:missing_partitions) { monthly_strategy.missing_partitions }
|
||||
|
||||
context 'with existing partitions' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01');
|
||||
SQL
|
||||
|
||||
# Insert some data, it doesn't make a difference
|
||||
model.create!(created_at: Date.parse('2020-04-20'))
|
||||
model.create!(created_at: Date.parse('2020-06-15'))
|
||||
end
|
||||
|
||||
context 'when pruning partitions before June 2020' do
|
||||
let(:retention_period) { 1.month }
|
||||
|
||||
it 'does not include the missing partition from May 2020 because it would be dropped' do
|
||||
expect(missing_partitions).not_to include(
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects the missing partition for 1 month ago (July 2020)' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-07-01', '2020-08-01', "#{model.table_name}_202007")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'detects the gap and the missing partition in May 2020' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects the missing partitions at the end of the range and expects a partition for July 2020' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-07-01', '2020-08-01', "#{model.table_name}_202007")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects the missing partitions at the end of the range and expects a partition for August 2020' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-08-01', '2020-09-01', "#{model.table_name}_202008")
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates partitions 6 months out from now (Sep 2020 through Feb 2021)' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-09-01', '2020-10-01', "#{model.table_name}_202009"),
|
||||
time_partition(table_name, '2020-10-01', '2020-11-01', "#{model.table_name}_202010"),
|
||||
time_partition(table_name, '2020-11-01', '2020-12-01', "#{model.table_name}_202011"),
|
||||
time_partition(table_name, '2020-12-01', '2021-01-01', "#{model.table_name}_202012"),
|
||||
time_partition(table_name, '2021-01-01', '2021-02-01', "#{model.table_name}_202101"),
|
||||
time_partition(table_name, '2021-02-01', '2021-03-01', "#{model.table_name}_202102")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects all missing partitions' do
|
||||
expect(missing_partitions.size).to eq(9)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without existing partitions' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
SQL
|
||||
end
|
||||
|
||||
context 'when pruning partitions before June 2020' do
|
||||
let(:retention_period) { 1.month }
|
||||
|
||||
it 'detects exactly the set of partitions from June 2020 to March 2021' do
|
||||
months = %w[2020-07-01 2020-08-01 2020-09-01 2020-10-01 2020-11-01 2020-12-01 2021-01-01 2021-02-01
|
||||
2021-03-01]
|
||||
expected = months[..-2].zip(months.drop(1)).map do |(from, to)|
|
||||
partition_name = "#{model.table_name}_#{Date.parse(from).strftime('%Y%m')}"
|
||||
time_partition(table_name, from, to, partition_name)
|
||||
end
|
||||
|
||||
expect(missing_partitions).to match_array(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'detects the missing catch-all partition at the beginning' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, nil, '2020-08-01', "#{model.table_name}_000000")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects the missing partition for today and expects a partition for August 2020' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-08-01', '2020-09-01', "#{model.table_name}_202008")
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates partitions 6 months out from now (Sep 2020 through Feb 2021' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, '2020-09-01', '2020-10-01', "#{model.table_name}_202009"),
|
||||
time_partition(table_name, '2020-10-01', '2020-11-01', "#{model.table_name}_202010"),
|
||||
time_partition(table_name, '2020-11-01', '2020-12-01', "#{model.table_name}_202011"),
|
||||
time_partition(table_name, '2020-12-01', '2021-01-01', "#{model.table_name}_202012"),
|
||||
time_partition(table_name, '2021-01-01', '2021-02-01', "#{model.table_name}_202101"),
|
||||
time_partition(table_name, '2021-02-01', '2021-03-01', "#{model.table_name}_202102")
|
||||
)
|
||||
end
|
||||
|
||||
it 'detects all missing partitions' do
|
||||
expect(missing_partitions.size).to eq(8)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a regular partition but no catchall (MINVALUE, to) partition' do
|
||||
before do
|
||||
connection.execute(<<~SQL)
|
||||
CREATE TABLE #{model.table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{model.table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01');
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'detects a missing catch-all partition to add before the existing partition' do
|
||||
expect(missing_partitions).to include(
|
||||
time_partition(table_name, nil, '2020-06-01', "#{model.table_name}_000000")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extra_partitions', time_travel_to: '2020-08-22' do
|
||||
subject(:extra_partitions) { monthly_strategy.extra_partitions }
|
||||
|
||||
describe 'with existing partitions' do
|
||||
before do
|
||||
ActiveRecord::Base.connection.execute(<<~SQL)
|
||||
CREATE TABLE #{table_name}
|
||||
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
|
||||
PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM (MINVALUE) TO ('2020-05-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-05-01') TO ('2020-06-01');
|
||||
|
||||
CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006
|
||||
PARTITION OF #{table_name}
|
||||
FOR VALUES FROM ('2020-06-01') TO ('2020-07-01')
|
||||
SQL
|
||||
end
|
||||
|
||||
context 'without a time retention policy' do
|
||||
it 'has no extra partitions to prune' do
|
||||
expect(extra_partitions).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy that excludes no partitions' do
|
||||
let(:retention_period) { 4.months }
|
||||
|
||||
it 'has no extra partitions to prune' do
|
||||
expect(extra_partitions).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 3 months' do
|
||||
let(:retention_period) { 3.months }
|
||||
|
||||
it 'prunes the unbounded partition ending 2020-05-01' do
|
||||
min_value_to_may = time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000")
|
||||
|
||||
expect(extra_partitions).to contain_exactly(min_value_to_may)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 2 months' do
|
||||
let(:retention_period) { 2.months }
|
||||
|
||||
it 'prunes the unbounded partition and the partition for May-June' do
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005")
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the retain_non_empty_partitions is true' do
|
||||
let(:retain_data) { true }
|
||||
|
||||
it 'prunes empty partitions' do
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005")
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not prune non-empty partitions' do
|
||||
# inserting one record into _test_partitioned_test_202005
|
||||
connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))")
|
||||
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a time retention policy of 1 month' do
|
||||
let(:retention_period) { 1.month }
|
||||
|
||||
it 'prunes the unbounded partition and the partitions for May-June and June-July' do
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005"),
|
||||
time_partition(table_name, '2020-06-01', '2020-07-01', "#{model.table_name}_202006")
|
||||
)
|
||||
end
|
||||
|
||||
it 'contains partitions starting at least one month in the past' do
|
||||
prune_to = extra_partitions.map(&:to).max
|
||||
expect(1.month.ago).to be_after(prune_to)
|
||||
|
||||
strategy = described_class.new(model, partitioning_key, retain_for: retention_period)
|
||||
desired_partitions = strategy.current_partitions - strategy.extra_partitions + strategy.missing_partitions
|
||||
# Double check this is equivalent to the private method
|
||||
expect(desired_partitions).to match_array(strategy.send(:desired_partitions))
|
||||
end
|
||||
|
||||
context 'when the retain_non_empty_partitions is true' do
|
||||
let(:retain_data) { true }
|
||||
|
||||
it 'prunes empty partitions' do
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-05-01', '2020-06-01', "#{model.table_name}_202005"),
|
||||
time_partition(table_name, '2020-06-01', '2020-07-01', "#{model.table_name}_202006")
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not prune non-empty partitions' do
|
||||
# inserting one record into _test_partitioned_test_202005
|
||||
connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))")
|
||||
|
||||
expect(extra_partitions).to contain_exactly(
|
||||
time_partition(table_name, nil, '2020-05-01', "#{model.table_name}_000000"),
|
||||
time_partition(table_name, '2020-06-01', '2020-07-01', "#{model.table_name}_202006")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'attributes' do
|
||||
let(:partitioning_key) { :partition }
|
||||
let(:retain_non_empty_partitions) { true }
|
||||
let(:retain_for) { 12.months }
|
||||
let(:analyze_interval) { 1.week }
|
||||
let(:model) { class_double(ApplicationRecord, table_name: table_name, connection: connection) }
|
||||
let(:table_name) { :_test_partitioned_test }
|
||||
|
||||
subject(:strategy) do
|
||||
described_class.new(
|
||||
model, partitioning_key,
|
||||
retain_for: retain_for,
|
||||
retain_non_empty_partitions: retain_non_empty_partitions,
|
||||
analyze_interval: analyze_interval
|
||||
)
|
||||
end
|
||||
|
||||
specify do
|
||||
expect(strategy).to have_attributes({
|
||||
model: model,
|
||||
partitioning_key: partitioning_key,
|
||||
retain_for: retain_for,
|
||||
retain_non_empty_partitions: retain_non_empty_partitions,
|
||||
analyze_interval: analyze_interval
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#partition_name' do
|
||||
let(:from) { Date.parse('2020-04-01 00:00:00') }
|
||||
let(:to) { Date.parse('2020-05-01 00:00:00') }
|
||||
|
||||
subject(:partition_name) { monthly_strategy.partition_name(from) }
|
||||
|
||||
it 'uses table_name as prefix' do
|
||||
expect(partition_name).to start_with(table_name)
|
||||
end
|
||||
|
||||
it 'uses Year-Month (from) as suffix' do
|
||||
expect(partition_name).to end_with("_202004")
|
||||
end
|
||||
|
||||
context 'without from date' do
|
||||
let(:from) { nil }
|
||||
|
||||
it 'uses 000000 as suffix for first partition' do
|
||||
expect(partition_name).to end_with("_000000")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def time_partition(table_name, lower_bound, upper_bound, partition_name)
|
||||
Gitlab::Database::Partitioning::TimePartition.new(
|
||||
table_name,
|
||||
lower_bound,
|
||||
upper_bound,
|
||||
partition_name: partition_name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -20,7 +20,7 @@ RSpec.describe PartitionedTable, feature_category: :database do
|
|||
subject { my_class.partitioned_by(key, strategy: :monthly, retain_for: 3.months) }
|
||||
|
||||
it 'passes the keyword arguments to the strategy' do
|
||||
expect(Gitlab::Database::Partitioning::MonthlyStrategy).to receive(:new).with(my_class, key, retain_for: 3.months).and_call_original
|
||||
expect(Gitlab::Database::Partitioning::Time::MonthlyStrategy).to receive(:new).with(my_class, key, retain_for: 3.months).and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
|
|
@ -29,7 +29,7 @@ RSpec.describe PartitionedTable, feature_category: :database do
|
|||
it 'assigns the MonthlyStrategy as the partitioning strategy' do
|
||||
subject
|
||||
|
||||
expect(my_class.partitioning_strategy).to be_a(Gitlab::Database::Partitioning::MonthlyStrategy)
|
||||
expect(my_class.partitioning_strategy).to be_a(Gitlab::Database::Partitioning::Time::MonthlyStrategy)
|
||||
end
|
||||
|
||||
it 'passes the partitioning key to the strategy instance' do
|
||||
|
|
|
|||
|
|
@ -10,20 +10,12 @@ RSpec.describe Namespaces::UserNamespace, type: :model do
|
|||
it { is_expected.to validate_presence_of(:owner) }
|
||||
end
|
||||
|
||||
describe 'owner methods' do
|
||||
describe '#owners' do
|
||||
let(:owner) { build(:user) }
|
||||
let(:namespace) { build(:namespace, owner: owner) }
|
||||
|
||||
describe '#owners' do
|
||||
specify do
|
||||
expect(namespace.owners).to match_array([owner])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#owner_ids' do
|
||||
specify do
|
||||
expect(namespace.owner_ids).to match_array([owner.id])
|
||||
end
|
||||
specify do
|
||||
expect(namespace.owners).to match_array([owner])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -170,15 +170,6 @@ RSpec.describe ProjectImportState, type: :model, feature_category: :importers do
|
|||
expect { import_state.finish }.to change { import_state.last_error }.from(error_message).to(nil)
|
||||
end
|
||||
|
||||
it 'sets the user mapping feature flag state from import data for other transitions' do
|
||||
import_state = create(:import_state, :started)
|
||||
import_state.project.build_or_assign_import_data(data: { user_contribution_mapping_enabled: true }).save!
|
||||
|
||||
import_state.finish
|
||||
|
||||
expect(import_state.user_mapping_enabled).to be(true)
|
||||
end
|
||||
|
||||
it 'enqueues housekeeping when an import of a fresh project is completed' do
|
||||
project = create(:project_empty_repo, :import_started, import_type: :github)
|
||||
|
||||
|
|
@ -239,15 +230,6 @@ RSpec.describe ProjectImportState, type: :model, feature_category: :importers do
|
|||
end.to change { project.import_data }
|
||||
.from(import_data).to(nil)
|
||||
end
|
||||
|
||||
it 'sets the user mapping feature flag state from import data for other transitions' do
|
||||
import_state = create(:import_state, :scheduled)
|
||||
import_state.project.build_or_assign_import_data(data: { user_contribution_mapping_enabled: true }).save!
|
||||
|
||||
import_state.cancel
|
||||
|
||||
expect(import_state.user_mapping_enabled).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'state transition: started: [:finished, :canceled, :failed]' do
|
||||
|
|
@ -282,92 +264,6 @@ RSpec.describe ProjectImportState, type: :model, feature_category: :importers do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'completion notification trigger', :aggregate_failures do
|
||||
let_it_be_with_reload(:group) { create(:group) }
|
||||
let_it_be_with_reload(:project) { create(:project, :import_user_mapping_enabled, namespace: group) }
|
||||
let_it_be(:group_owner) { create(:user) }
|
||||
|
||||
let(:user_mapping_enabled) { true }
|
||||
|
||||
subject(:import_state) { create(:import_state, status: initial_status, project: project) }
|
||||
|
||||
before_all do
|
||||
group.add_owner(group_owner)
|
||||
group.add_owner(project.creator)
|
||||
end
|
||||
|
||||
shared_examples 'only the project creator is notified' do |event|
|
||||
it 'only sends completion email to the project creator' do
|
||||
expect(Notify).to receive(:project_import_complete)
|
||||
.with(project.id, project.creator_id, user_mapping_enabled)
|
||||
.and_call_original
|
||||
|
||||
import_state.send(:"#{event}!")
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'not a completing event' do |event, initial_status|
|
||||
let(:initial_status) { initial_status }
|
||||
|
||||
it 'does not send any completion emails' do
|
||||
expect(Notify).not_to receive(:project_import_complete)
|
||||
|
||||
import_state.send(:"#{event}!")
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'not a completing event', :schedule, :none
|
||||
it_behaves_like 'not a completing event', :force_start, :none
|
||||
it_behaves_like 'not a completing event', :start, :scheduled
|
||||
it_behaves_like 'not a completing event', :cancel, :started
|
||||
|
||||
context 'when the import state transitions to finished' do
|
||||
let(:event) { :finish }
|
||||
let(:initial_status) { :started }
|
||||
|
||||
it 'sends a completion email to the project creator and group owners' do
|
||||
allow(Notify).to receive(:project_import_complete).and_call_original
|
||||
|
||||
import_state.send(:"#{event}!")
|
||||
|
||||
expect(Notify).to have_received(:project_import_complete).with(project.id, project.creator_id, true)
|
||||
expect(Notify).to have_received(:project_import_complete).with(project.id, group_owner.id, true)
|
||||
end
|
||||
|
||||
context 'when the project was imported to a personal namespace' do
|
||||
let_it_be(:project) { create(:project, :import_user_mapping_enabled) }
|
||||
|
||||
it_behaves_like 'only the project creator is notified', :finish
|
||||
end
|
||||
|
||||
context 'when user mapping is disabled' do
|
||||
let(:user_mapping_enabled) { false }
|
||||
|
||||
before do
|
||||
project.build_or_assign_import_data(data: { user_contribution_mapping_enabled: user_mapping_enabled }).save!
|
||||
end
|
||||
|
||||
it_behaves_like 'only the project creator is notified', :finish
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the import state transitions to failed' do
|
||||
let(:initial_status) { :started }
|
||||
|
||||
it_behaves_like 'only the project creator is notified', :fail_op
|
||||
|
||||
context 'when user mapping is disabled' do
|
||||
let(:user_mapping_enabled) { false }
|
||||
|
||||
before do
|
||||
project.build_or_assign_import_data(data: { user_contribution_mapping_enabled: user_mapping_enabled }).save!
|
||||
end
|
||||
|
||||
it_behaves_like 'only the project creator is notified', :fail_op
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'clearing `jid` after finish', :clean_gitlab_redis_cache do
|
||||
context 'without an JID' do
|
||||
it 'does nothing' do
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ RSpec.describe DraftNotes::PublishService, feature_category: :code_review_workfl
|
|||
publish
|
||||
end
|
||||
|
||||
it 'resolves todos for the MR' do
|
||||
expect_any_instance_of(TodoService) do |todo_service|
|
||||
expect(todo_service).to receive(:new_review).with(kind_of(Review), user)
|
||||
end
|
||||
|
||||
publish
|
||||
end
|
||||
|
||||
it 'tracks the publish event' do
|
||||
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
|
||||
.to receive(:track_publish_review_action)
|
||||
|
|
@ -243,7 +251,7 @@ RSpec.describe DraftNotes::PublishService, feature_category: :code_review_workfl
|
|||
|
||||
recorder = ActiveRecord::QueryRecorder.new(skip_cached: false) { publish }
|
||||
|
||||
expect(recorder.count).not_to be > 112
|
||||
expect(recorder.count).not_to be > 116
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1285,6 +1285,32 @@ RSpec.describe TodoService, feature_category: :notifications do
|
|||
should_not_create_todo(user: guest, target: mentioned_mr, action: Todo::MENTIONED)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#new_review' do
|
||||
it 'marks related pending todos to the target MR for the user as done' do
|
||||
first_todo = create(:todo, :pending, :assigned, user: john_doe, project: project, target: mentioned_mr, author: author)
|
||||
second_todo = create(:todo, :pending, :review_requested, user: john_doe, project: project, target: mentioned_mr, author: author)
|
||||
third_todo = create(:todo, :pending, :mentioned, user: john_doe, project: project, target: mentioned_mr, author: author)
|
||||
|
||||
review = Review.new(merge_request: mentioned_mr)
|
||||
service.new_review(review, john_doe)
|
||||
|
||||
expect(first_todo.reload).to be_done
|
||||
expect(second_todo.reload).to be_done
|
||||
expect(third_todo.reload).to be_done
|
||||
end
|
||||
|
||||
it 'marks related pending todo to the target MR for the user as done when the multiple_todos feature is off' do
|
||||
stub_feature_flags(multiple_todos: false)
|
||||
|
||||
only_todo = create(:todo, :pending, :assigned, user: john_doe, project: project, target: mentioned_mr, author: author)
|
||||
|
||||
review = Review.new(merge_request: mentioned_mr)
|
||||
service.new_review(review, john_doe)
|
||||
|
||||
expect(only_todo.reload).to be_done
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Designs' do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe WorkItems::DataSync::Widgets::TimeTracking, feature_category: :team_planning do
|
||||
let_it_be(:current_user) { create(:user) }
|
||||
let_it_be_with_reload(:work_item) { create(:work_item) }
|
||||
let_it_be_with_reload(:target_work_item) { create(:work_item) }
|
||||
let_it_be(:timelogs) { create_list(:timelog, 3, issue: work_item) }
|
||||
|
||||
let(:params) { { operation: :move } }
|
||||
|
||||
subject(:callback) do
|
||||
described_class.new(
|
||||
work_item: work_item, target_work_item: target_work_item, current_user: current_user, params: params
|
||||
)
|
||||
end
|
||||
|
||||
describe '#before_create' do
|
||||
let_it_be(:work_item) { create(:work_item, time_estimate: 3600) }
|
||||
|
||||
context 'when target work item has time_tracking widget' do
|
||||
before do
|
||||
allow(target_work_item).to receive(:get_widget).with(:time_tracking).and_return(true)
|
||||
end
|
||||
|
||||
it "updates the time_estimate attributes in target work item" do
|
||||
expect { callback.before_create }.to change { target_work_item.time_estimate }.from(0).to(3600)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when target work item does not have time_tracking widget' do
|
||||
before do
|
||||
allow(target_work_item).to receive(:get_widget).with(:time_tracking).and_return(false)
|
||||
end
|
||||
|
||||
it "does not update the time_estimate attributes in target work item" do
|
||||
expect { callback.before_create }.not_to change { target_work_item.time_estimate }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#after_save_commit' do
|
||||
context 'when target work item has time_tracking widget' do
|
||||
before do
|
||||
allow(target_work_item).to receive(:get_widget).with(:time_tracking).and_return(true)
|
||||
end
|
||||
|
||||
it 'calls the copy timelogs worker' do
|
||||
expect(WorkItems::CopyTimelogsWorker).to receive(:perform_async).with(work_item.id, target_work_item.id)
|
||||
|
||||
callback.after_save_commit
|
||||
end
|
||||
|
||||
context 'when cloning work item' do
|
||||
let(:params) { { operation: :clone } }
|
||||
|
||||
it 'does not call the copy timelogs worker' do
|
||||
expect(WorkItems::CopyTimelogsWorker).not_to receive(:perform_async)
|
||||
|
||||
callback.after_save_commit
|
||||
end
|
||||
end
|
||||
|
||||
context "when work_item does not have timelogs" do
|
||||
before do
|
||||
work_item.timelogs.delete_all
|
||||
end
|
||||
|
||||
it 'does not call the copy timelogs worker' do
|
||||
expect(WorkItems::CopyTimelogsWorker).not_to receive(:perform_async)
|
||||
|
||||
callback.after_save_commit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when target work item does not have time_tracking widget' do
|
||||
before do
|
||||
allow(target_work_item).to receive(:get_widget).with(:time_tracking).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not call the copy timelogs worker' do
|
||||
expect(WorkItems::CopyTimelogsWorker).not_to receive(:perform_async)
|
||||
|
||||
callback.after_save_commit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#post_move_cleanup' do
|
||||
it "deletes all the original work item timelog records" do
|
||||
expect { callback.post_move_cleanup }.to change { work_item.timelogs.count }.from(3).to(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -88,6 +88,10 @@ RSpec.shared_examples 'cloneable and moveable widget data' do
|
|||
work_item.reload.sent_notifications.pluck(:recipient_id)
|
||||
end
|
||||
|
||||
def work_item_timelogs(work_item)
|
||||
work_item.reload.timelogs.pluck(:user_id, :time_spent)
|
||||
end
|
||||
|
||||
def work_item_crm_contacts(work_item)
|
||||
work_item.reload.customer_relations_contacts
|
||||
end
|
||||
|
|
@ -169,6 +173,11 @@ RSpec.shared_examples 'cloneable and moveable widget data' do
|
|||
milestone.title
|
||||
end
|
||||
|
||||
let_it_be(:timelogs) do
|
||||
timelogs = create_list(:timelog, 2, issue: original_work_item)
|
||||
timelogs.pluck(:user_id, :time_spent)
|
||||
end
|
||||
|
||||
where(:widget_name, :eval_value, :expected_data, :operations) do
|
||||
:assignees | :work_item_assignees | ref(:assignees) | [ref(:move), ref(:clone)]
|
||||
:award_emoji | :work_item_award_emoji | ref(:award_emojis) | [ref(:move)]
|
||||
|
|
@ -176,6 +185,7 @@ RSpec.shared_examples 'cloneable and moveable widget data' do
|
|||
:milestone | :work_item_milestone | ref(:milestone) | [ref(:move), ref(:clone)]
|
||||
:subscriptions | :work_item_subscriptions | ref(:subscriptions) | [ref(:move)]
|
||||
:sent_notifications | :work_item_sent_notifications | ref(:notifications) | [ref(:move)]
|
||||
:timelogs | :work_item_timelogs | ref(:timelogs) | [ref(:move)]
|
||||
:customer_relations_contacts | :work_item_crm_contacts | ref(:crm_contacts) | [ref(:move), ref(:clone)]
|
||||
end
|
||||
|
||||
|
|
@ -183,6 +193,9 @@ RSpec.shared_examples 'cloneable and moveable widget data' do
|
|||
context "with widget" do
|
||||
before do
|
||||
allow(original_work_item).to receive(:from_service_desk?).and_return(true)
|
||||
allow(WorkItems::CopyTimelogsWorker).to receive(:perform_async) do |*args|
|
||||
WorkItems::CopyTimelogsWorker.perform_inline(*args)
|
||||
end
|
||||
end
|
||||
|
||||
it 'clones and moves widget data', :aggregate_failures do
|
||||
|
|
|
|||
Loading…
Reference in New Issue