Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-04-19 15:08:32 +00:00
parent 2ef0b7f13d
commit 846dc476d8
64 changed files with 896 additions and 319 deletions

View File

@ -93,6 +93,7 @@ variables:
CHECK_PRECOMPILED_ASSETS: "true"
FF_USE_FASTZIP: "true"
SKIP_FLAKY_TESTS_AUTOMATICALLY: "true"
RETRY_FAILED_TESTS_IN_NEW_PROCESS: "true"
# Run with decomposed databases by default
DECOMPOSED_DB: "true"

View File

@ -1 +1 @@
13.25.0
13.25.1

View File

@ -1,12 +1,20 @@
<script>
import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf, GlBadge } from '@gitlab/ui';
import {
GlDropdown,
GlTooltipDirective,
GlIcon,
GlLink,
GlSprintf,
GlBadge,
GlAvatar,
GlAvatarLink,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
@ -41,7 +49,8 @@ export default {
StopComponent,
TerminalButtonComponent,
TooltipOnTruncate,
UserAvatarLink,
GlAvatar,
GlAvatarLink,
CiIcon,
},
directives: {
@ -649,22 +658,27 @@ export default {
class="table-section deployment-column d-none d-md-block"
:class="tableData.deploy.spacing"
role="gridcell"
data-testid="enviornment-deployment-id-cell"
data-testid="environment-deployment-id-cell"
>
<span v-if="shouldRenderDeploymentID" class="text-break-word">
{{ deploymentInternalId }}
</span>
<span v-if="!isFolder && deploymentHasUser" class="text-break-word">
<span
v-if="!isFolder && deploymentHasUser"
class="text-break-word gl-display-inline-flex gl-align-items-center"
>
<gl-sprintf :message="s__('Environments|by %{avatar}')">
<template #avatar>
<user-avatar-link
:link-href="deploymentUser.web_url"
:img-src="deploymentUser.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="deploymentUser.username"
class="js-deploy-user-container float-none"
/>
<gl-avatar-link :href="deploymentUser.web_url" class="gl-ml-2">
<gl-avatar
:src="deploymentUser.avatar_url"
:entity-name="deploymentUser.username"
:title="deploymentUser.username"
:alt="userImageAltDescription"
:size="24"
/>
</gl-avatar-link>
</template>
</gl-sprintf>
</span>
@ -753,20 +767,24 @@ export default {
<ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" />
</gl-link>
</div>
<div class="gl-display-flex">
<span v-if="upcomingDeployment.user" class="text-break-word">
<gl-sprintf :message="s__('Environments|by %{avatar}')">
<template #avatar>
<user-avatar-link
:link-href="upcomingDeployment.user.web_url"
:img-src="upcomingDeployment.user.avatar_url"
:img-alt="upcomingDeploymentUserImageAltDescription"
:tooltip-text="upcomingDeployment.user.username"
<span
v-if="upcomingDeployment.user"
class="text-break-word gl-display-inline-flex gl-align-items-center gl-mt-2"
>
<gl-sprintf :message="s__('Environments|by %{avatar}')">
<template #avatar>
<gl-avatar-link :href="upcomingDeployment.user.web_url" class="gl-ml-2">
<gl-avatar
:src="upcomingDeployment.user.avatar_url"
:alt="upcomingDeploymentUserImageAltDescription"
:entity-name="upcomingDeployment.user.username"
:title="upcomingDeployment.user.username"
:size="24"
/>
</template>
</gl-sprintf>
</span>
</div>
</gl-avatar-link>
</template>
</gl-sprintf>
</span>
</div>
</div>

View File

@ -112,6 +112,9 @@ export default {
count: this.searchOptions.length,
});
},
headerSearchActivityDescriptor() {
return this.showDropdown ? 'is-active' : 'is-not-active';
},
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
@ -143,7 +146,8 @@ export default {
v-outside="closeDropdown"
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative"
class="header-search gl-relative gl-rounded-base"
:class="headerSearchActivityDescriptor"
>
<gl-search-box-by-type
id="search"

View File

@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui';
import api from '~/api';
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
import IssuesList from './issues_list.vue';
@ -13,7 +13,7 @@ export default {
components: {
GlButton,
IssuesList,
Popover,
HelpPopover,
StatusIcon,
},
mixins: [glFeatureFlagsMixin()],
@ -193,7 +193,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
<slot :name="slotName"></slot>
<popover
<help-popover
v-if="hasPopover"
:options="popoverOptions"
class="gl-ml-2 gl-display-inline-flex"

View File

@ -45,6 +45,36 @@ input[type='checkbox']:hover {
transition: border-color ease-in-out $default-transition-duration,
background-color ease-in-out $default-transition-duration;
}
&.is-not-active {
.btn.gl-clear-icon-button {
display: none;
}
&::after {
content: '/';
display: inline-block;
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
font-size: $gl-font-size-small;
font-family: $monospace-font;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: 3px;
box-shadow: none;
white-space: pre-wrap;
// Safari
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
}
}
.header-search-dropdown-menu {

View File

@ -1516,6 +1516,29 @@ svg.s16 {
.header-search {
width: 320px;
}
.header-search.is-not-active::after {
content: "/";
display: inline-block;
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
font-size: 12px;
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: 3px;
box-shadow: none;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
.search {
margin: 0 8px;
}
@ -1854,6 +1877,10 @@ body.gl-dark .header-search input::placeholder {
body.gl-dark .header-search input:active::placeholder {
color: #868686;
}
body.gl-dark .header-search.is-not-active::after {
color: #fafafa;
background-color: rgba(250, 250, 250, 0.2);
}
body.gl-dark .search form {
background-color: rgba(250, 250, 250, 0.2);
}

View File

@ -1502,6 +1502,29 @@ svg.s16 {
.header-search {
width: 320px;
}
.header-search.is-not-active::after {
content: "/";
display: inline-block;
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
font-size: 12px;
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: 3px;
box-shadow: none;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
.search {
margin: 0 8px;
}

View File

@ -176,6 +176,11 @@
}
}
}
&.is-not-active::after {
color: $search-and-nav-links;
background-color: rgba($search-and-nav-links, 0.2);
}
}
.search {

View File

@ -13,6 +13,7 @@ class AutocompleteController < ApplicationController
feature_category :continuous_delivery, [:deploy_keys_with_owners]
urgency :low, [:merge_request_target_branches]
urgency :default, [:users]
def users
group = Autocomplete::GroupFinder

View File

@ -25,6 +25,9 @@ class Explore::ProjectsController < Explore::ApplicationController
feature_category :projects
# TODO: Set higher urgency after addressing https://gitlab.com/gitlab-org/gitlab/-/issues/357913
urgency :low, [:index]
def index
show_alert_if_search_is_disabled
@projects = load_projects

View File

@ -9,6 +9,9 @@ module Groups
feature_category :subgroups
# TODO: Set to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/331494
urgency :low, [:index]
def index
params[:sort] ||= @group_projects_sort
parent = if params[:parent_id].present?

View File

@ -20,7 +20,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
:approve_access_request, :leave, :resend_invite,
:override
feature_category :authentication_and_authorization
feature_category :subgroups
def index
push_frontend_feature_flag(:group_member_inherited_group, @group)

View File

@ -59,7 +59,8 @@ class GroupsController < Groups::ApplicationController
feature_category :importers, [:export, :download_export]
urgency :high, [:unfoldered_environment_names]
urgency :low, [:merge_requests]
# TODO: Set #show to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/334795
urgency :low, [:merge_requests, :show]
def index
redirect_to(current_user ? dashboard_groups_path : explore_groups_path)

View File

@ -9,6 +9,8 @@ class JwtController < ApplicationController
prepend_before_action :auth_user, :authenticate_project_or_user
feature_category :authentication_and_authorization
# https://gitlab.com/gitlab-org/gitlab/-/issues/357037
urgency :low
SERVICES = {
::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService,

View File

@ -8,7 +8,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
feature_category :authentication_and_authorization
feature_category :projects
def index
@sort = params[:sort].presence || sort_value_name

View File

@ -56,7 +56,8 @@ class ProjectsController < Projects::ApplicationController
feature_category :code_review, [:unfoldered_environment_names]
feature_category :portfolio_management, [:planning_hierarchy]
urgency :low, [:refs]
# TODO: Set high urgency for #show https://gitlab.com/gitlab-org/gitlab/-/issues/334444
urgency :low, [:refs, :show]
urgency :high, [:unfoldered_environment_names]
def index

View File

@ -33,6 +33,9 @@ class UsersController < ApplicationController
feature_category :snippets, [:snippets]
# TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357914
urgency :low, [:show]
def show
respond_to do |format|
format.html

View File

@ -73,10 +73,20 @@ class UserRecentEventsFinder
return Event.none if users.empty?
if event_filter.filter == EventFilter::ALL
execute_optimized_multi(users)
if Feature.enabled?(:optimized_followed_users_queries, current_user)
query_builder_params = event_filter.in_operator_query_builder_params(users)
Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
.new(**query_builder_params)
.execute
.limit(limit)
.offset(params[:offset] || 0)
else
event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
if event_filter.filter == EventFilter::ALL
execute_optimized_multi(users)
else
event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
end
end
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -19,6 +19,8 @@ class AwardEmoji < ApplicationRecord
participant :user
delegate :resource_parent, to: :awardable, allow_nil: true
scope :downvotes, -> { named(DOWNVOTE_NAME) }
scope :upvotes, -> { named(UPVOTE_NAME) }
scope :named, -> (names) { where(name: names) }
@ -61,7 +63,9 @@ class AwardEmoji < ApplicationRecord
end
def url
awardable.try(:namespace)&.custom_emoji&.by_name(name)&.first&.url
return if TanukiEmoji.find_by_alpha_code(name)
CustomEmoji.for_resource(resource_parent).by_name(name).select(:url).first&.url
end
def expire_cache

View File

@ -28,6 +28,19 @@ class CustomEmoji < ApplicationRecord
alias_attribute :url, :file # this might need a change in https://gitlab.com/gitlab-org/gitlab/-/issues/230467
# Find custom emoji for the given resource.
# A resource can be either a Project or a Group, or anything responding to #root_ancestor.
# Usually it's the return value of #resource_parent on any model.
scope :for_resource, -> (resource) do
return none if resource.nil?
namespace = resource.root_ancestor
return none if namespace.nil? || Feature.disabled?(:custom_emoji, namespace)
namespace.custom_emoji
end
private
def valid_emoji_name

View File

@ -31,8 +31,9 @@ class Event < ApplicationRecord
private_constant :ACTIONS
WIKI_ACTIONS = [:created, :updated, :destroyed].freeze
DESIGN_ACTIONS = [:created, :updated, :destroyed].freeze
TEAM_ACTIONS = [:joined, :left, :expired].freeze
ISSUE_ACTIONS = [:created, :updated, :closed, :reopened].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,

View File

@ -40,6 +40,7 @@ class Snippet < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :project
alias_method :resource_parent, :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

View File

@ -24,11 +24,9 @@ module Gitlab
end
def valid_custom_emoji?(record, value)
namespace = record.try(:awardable).try(:namespace)
resource = record.try(:resource_parent)
return unless namespace
namespace.custom_emoji&.by_name(value.to_s)&.any?
CustomEmoji.for_resource(resource).by_name(value.to_s).any?
end
end
end

View File

@ -1,4 +1,4 @@
#js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json,
#js-header-search.header-search.is-not-active.gl-relative{ data: { 'search-context' => header_search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path,

View File

@ -0,0 +1,8 @@
---
name: optimized_followed_users_queries
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84856
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/358649
milestone: '14.10'
type: development
group: group::optimize
default_enabled: false

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddAsyncIndexForEventsFollowedUsers < Gitlab::Database::Migration[1.0]
INDEX_NAME = 'index_events_for_followed_users'
def up
prepare_async_index :events, %I[author_id target_type action id], name: INDEX_NAME
end
def down
unprepare_async_index :events, %I[author_id target_type action id], name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
7952024a6a8df98842fa23ca9a4c328b83816ded3071e7597dbab431a5561e1a

View File

@ -20,7 +20,7 @@ Branch pipelines:
- Run when you push a new commit to a branch.
- Are the default type of pipeline.
- Have access to [some predefined variables](../variables/predefined_variables.md).
- Have access to [protected variables](../variables/index.md#protect-a-cicd-variable) and [protected runners](../runners/configure_runners.md#prevent-runners-from-revealing-sensitive-information.
- Have access to [protected variables](../variables/index.md#protect-a-cicd-variable) and [protected runners](../runners/configure_runners.md#prevent-runners-from-revealing-sensitive-information).
Merge request pipelines:

View File

@ -187,7 +187,7 @@ See the [experiment issue](https://gitlab.com/gitlab-org/quality/team-tasks/-/is
#### Automatic retry of failing tests in a separate process
When the `$RETRY_FAILED_TESTS_IN_NEW_PROCESS` variable is set to `true`, RSpec tests that failed are automatically retried once in a separate
Unless `$RETRY_FAILED_TESTS_IN_NEW_PROCESS` variable is set to `false` (`true` by default), RSpec tests that failed are automatically retried once in a separate
RSpec process. The goal is to get rid of most side-effects from previous tests that may lead to a subsequent test failure.
We keep track of retried tests in the `$RETRIED_TESTS_REPORT_FILE` file saved as artifact by the `rspec:flaky-tests-report` job.

View File

@ -22,7 +22,8 @@ Prerequisites:
To configure your project:
1. Go to your project and select [**Settings > Integrations**](../../user/project/integrations/overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Jira**.
1. Select **Enable integration**.
1. Select **Trigger** actions. Your choice determines whether a mention of Jira issue

View File

@ -199,6 +199,17 @@ cd /home/git/gitlab
git diff origin/PREVIOUS_BRANCH:config/gitlab.yml.example origin/BRANCH:config/gitlab.yml.example
```
#### New configuration options for `database.yml`
There might be configuration options available for [`database.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/database.yml.postgresql).
View them with the command below and apply them manually to your current `database.yml`:
```shell
cd /home/git/gitlab
git diff origin/PREVIOUS_BRANCH:config/database.yml.postgresql origin/BRANCH:config/database.yml.postgresql
```
#### NGINX configuration
Ensure you're still up-to-date with the latest NGINX configuration changes:

View File

@ -32,8 +32,8 @@ In Asana, create a Personal Access Token.
Complete these steps in GitLab:
1. Go to the project you want to configure.
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Asana**.
1. Ensure that the **Active** toggle is enabled.
1. Paste the token you generated in Asana.

View File

@ -14,7 +14,8 @@ You can configure Bugzilla as an
To enable the Bugzilla integration in a project:
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Bugzilla**.
1. Select the checkbox under **Enable integration**.
1. Fill in the required fields:

View File

@ -26,8 +26,9 @@ and configure it in GitLab.
With the webhook URL created in the Discord channel, you can set up the Discord Notifications service in GitLab.
1. Navigate to the [Integrations page](overview.md#accessing-integrations) in your project's settings. That is, **Project > Settings > Integrations**.
1. Select the **Discord Notifications** integration to configure it.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Discord Notifications**.
1. Ensure that the **Active** toggle is enabled.
1. Check the checkboxes corresponding to the GitLab events for which you want to send notifications to Discord.
1. Paste the webhook URL that you copied from the create Discord webhook step.

View File

@ -9,17 +9,18 @@ info: To determine the technical writer assigned to the Stage/Group associated w
By enabling this service, you receive email notifications for every change
that is pushed to your project.
From the [Integrations page](overview.md#accessing-integrations)
select **Emails on push** service to activate and configure it.
To enable emails on push:
In the _Recipients_ area, provide a list of emails separated by spaces or newlines.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Emails on push**.
1. In the **Recipients** section, provide a list of emails separated by spaces or newlines.
1. Configure the following options:
The following options are available:
- **Push events** - Email is triggered when a push event is received.
- **Tag push events** - Email is triggered when a tag is created and pushed.
- **Send from committer** - Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as `user@gitlab.com`).
- **Disable code diffs** - Don't include possibly sensitive code diffs in notification body.
- **Push events** - Email is triggered when a push event is received.
- **Tag push events** - Email is triggered when a tag is created and pushed.
- **Send from committer** - Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as `user@gitlab.com`).
- **Disable code diffs** - Don't include possibly sensitive code diffs in notification body.
| Settings | Notification |
| --- | --- |

View File

@ -14,7 +14,8 @@ This IBM product was [formerly named Rational Team Concert](https://jazz.net/blo
To enable the EWM integration, in a project:
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **EWM**.
1. Select the checkbox under **Enable integration**.
1. Fill in the required fields:

View File

@ -39,9 +39,8 @@ network. For more details, read
## Complete these steps in GitLab
1. On the top bar, select **Menu > Projects** and find the project you want to
configure for notifications.
1. Navigate to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **irker (IRC gateway)**.
1. Ensure that the **Active** toggle is enabled.
1. Optional. Under **Server host**, enter the server host address where `irkerd` runs. If empty,

View File

@ -37,27 +37,25 @@ Display name override is not enabled by default, you need to ask your administra
## Configure GitLab to send notifications to Mattermost
After the Mattermost instance has an incoming webhook set up, you can set up GitLab
to send the notifications.
to send the notifications:
Navigate to the [Integrations page](overview.md#accessing-integrations)
and select the **Mattermost notifications** service. Select the GitLab events
you want to generate notifications for.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Mattermost notifications**.
1. Select the GitLab events to generate notifications for. For each event you select, input the Mattermost channel
to receive the notification. You do not need to add the hash sign (`#`).
1. Fill in the integration configuration:
For each event you select, input the Mattermost channel you want to receive the
notification. You do not need to add the hash sign (`#`).
Then fill in the integration configuration:
- **Webhook**: The incoming webhook URL on Mattermost, similar to
`http://mattermost.example/hooks/5xo…`.
- **Username**: Optional. The username shown in messages sent to Mattermost.
To change the bot's username, provide a value.
- **Notify only broken pipelines**: If you enable the **Pipeline** event, and you want
notifications about failed pipelines only.
- **Branches for which notifications are to be sent**: The branches to send notifications for.
- **Labels to be notified**: Optional. Labels required for the issue or merge request
to trigger a notification. Leave blank to notify for all issues and merge requests.
- **Labels to be notified behavior**: When you use the **Labels to be notified** filter,
messages are sent when an issue or merge request contains _any_ of the labels specified
in the filter. You can also choose to trigger messages only when the issue or merge request
contains _all_ the labels defined in the filter.
- **Webhook**: The incoming webhook URL on Mattermost, similar to
`http://mattermost.example/hooks/5xo…`.
- **Username**: Optional. The username shown in messages sent to Mattermost.
To change the bot's username, provide a value.
- **Notify only broken pipelines**: If you enable the **Pipeline** event, and you want
notifications about failed pipelines only.
- **Branches for which notifications are to be sent**: The branches to send notifications for.
- **Labels to be notified**: Optional. Labels required for the issue or merge request
to trigger a notification. Leave blank to notify for all issues and merge requests.
- **Labels to be notified behavior**: When you use the **Labels to be notified** filter,
messages are sent when an issue or merge request contains _any_ of the labels specified
in the filter. You can also choose to trigger messages only when the issue or merge request
contains _all_ the labels defined in the filter.

View File

@ -37,8 +37,8 @@ In Pivotal Tracker, [create an API token](https://www.pivotaltracker.com/help/ar
Complete these steps in GitLab:
1. Go to the project you want to configure.
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Pivotal Tracker**.
1. Ensure that the **Active** toggle is enabled.
1. Paste the token you generated in Pivotal Tracker.

View File

@ -62,9 +62,9 @@ GitLab can use these to access the resource. More information about authenticati
service account can be found at Google's documentation for
[Authenticating from a service account](https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_a_service_account).
1. Navigate to the [Integrations page](overview.md#accessing-integrations) at
**Settings > Integrations**.
1. Click the **Prometheus** service.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Prometheus**.
1. For **API URL**, provide the domain name or IP address of your server, such as
`http://prometheus.example.com/` or `http://192.0.2.1/`.
1. (Optional) In **Google IAP Audience Client ID**, provide the Client ID of the
@ -73,7 +73,7 @@ service account can be found at Google's documentation for
Service Account credentials file that is authorized to access the Prometheus resource.
The JSON key `token_credential_uri` is discarded to prevent
[Server-side Request Forgery (SSRF)](https://www.hackerone.com/application-security/how-server-side-request-forgery-ssrf).
1. Click **Save changes**.
1. Select **Save changes**.
![Configure Prometheus Service](img/prometheus_manual_configuration_v13_2.png)
@ -83,11 +83,12 @@ You can configure [Thanos](https://thanos.io/) as a drop-in replacement for Prom
with GitLab. Use the domain name or IP address of the Thanos server you'd like
to integrate with.
1. Navigate to the [Integrations page](overview.md#accessing-integrations).
1. Click the **Prometheus** service.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Prometheus**.
1. Provide the domain name or IP address of your server, for example
`http://thanos.example.com/` or `http://192.0.2.1/`.
1. Click **Save changes**.
1. Select **Save changes**.
### Precedence with multiple Prometheus configurations

View File

@ -10,7 +10,8 @@ Use [Redmine](https://www.redmine.org/) as the issue tracker.
To enable the Redmine integration in a project:
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Redmine**.
1. Select the checkbox under **Enable integration**.
1. Fill in the required fields:

View File

@ -18,7 +18,7 @@ For GitLab.com, use the [GitLab Slack app](gitlab_slack_application.md) instead.
## Configure GitLab and Slack
Slack slash command [integrations](overview.md#accessing-integrations)
Slack slash command integrations
are scoped to a project.
1. In GitLab, on the top bar, select **Menu > Projects** and find your project.

View File

@ -15,7 +15,8 @@ copy its URL.
In GitLab:
1. Go to the [Integrations page](overview.md#accessing-integrations) in your project's settings.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Unify Circuit**.
1. Turn on the **Active** toggle.
1. Select the checkboxes corresponding to the GitLab events you want to receive in Unify Circuit.

View File

@ -14,7 +14,8 @@ You can configure YouTrack as an
To enable the YouTrack integration in a project:
1. Go to the [Integrations page](overview.md#accessing-integrations).
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **YouTrack**.
1. Select the checkbox under **Enable integration**.
1. Fill in the required fields:

View File

@ -249,7 +249,8 @@ module API
use :with_custom_attributes
optional :with_projects, type: Boolean, default: true, desc: 'Omit project details'
end
get ":id", feature_category: :subgroups do
# TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357841
get ":id", feature_category: :subgroups, urgency: :low do
group = find_group!(params[:id])
group.preload_shared_group_links
@ -300,7 +301,8 @@ module API
use :with_custom_attributes
use :optional_projects_params
end
get ":id/projects", feature_category: :subgroups do
# TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/211498
get ":id/projects", feature_category: :subgroups, urgency: :low do
finder_options = {
only_owned: !params[:with_shared],
include_subgroups: params[:include_subgroups],
@ -347,7 +349,7 @@ module API
use :group_list_params
use :with_custom_attributes
end
get ":id/subgroups", feature_category: :subgroups do
get ":id/subgroups", feature_category: :subgroups, urgency: :low do
groups = find_groups(declared_params(include_missing: false), params[:id])
present_groups params, groups
end

View File

@ -7,6 +7,7 @@ module API
before { authenticate! }
feature_category :authentication_and_authorization
urgency :low
helpers ::API::Helpers::MembersHelpers

View File

@ -8,6 +8,9 @@ module API
feature_category :users
# TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357839
urgency :low
params do
requires :id, type: String, desc: 'The ID of a project'
end

View File

@ -214,7 +214,7 @@ module API
use :statistics_params
use :with_custom_attributes
end
get ":user_id/projects", feature_category: :projects do
get ":user_id/projects", feature_category: :projects, urgency: :default do
user = find_user(params[:user_id])
not_found!('User') unless user
@ -251,7 +251,8 @@ module API
use :statistics_params
use :with_custom_attributes
end
get feature_category: :projects do
# TODO: Set higher urgency https://gitlab.com/gitlab-org/gitlab/-/issues/211495
get feature_category: :projects, urgency: :low do
present_projects load_projects
end
@ -340,7 +341,8 @@ module API
optional :license, type: Boolean, default: false,
desc: 'Include project license data'
end
get ":id", feature_category: :projects do
# TODO: Set higher urgency https://gitlab.com/gitlab-org/gitlab/-/issues/357622
get ":id", feature_category: :projects, urgency: :default do
options = {
with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails,
current_user: current_user,

View File

@ -99,7 +99,7 @@ module API
use :optional_index_params_ee
end
# rubocop: disable CodeReuse/ActiveRecord
get feature_category: :users do
get feature_category: :users, urgency: :default do
authenticated_as_admin! if params[:extern_uid].present? && params[:provider].present?
unless current_user&.admin?
@ -143,7 +143,7 @@ module API
use :with_custom_attributes
end
# rubocop: disable CodeReuse/ActiveRecord
get ":id", feature_category: :users do
get ":id", feature_category: :users, urgency: :medium do
forbidden!('Not authorized!') unless current_user
unless current_user.admin?
@ -168,7 +168,7 @@ module API
params do
requires :user_id, type: String, desc: 'The ID or username of the user'
end
get ":user_id/status", requirements: API::USER_REQUIREMENTS, feature_category: :users do
get ":user_id/status", requirements: API::USER_REQUIREMENTS, feature_category: :users, urgency: :high do
user = find_user(params[:user_id])
not_found!('User') unless user && can?(current_user, :read_user, user)
@ -919,7 +919,7 @@ module API
desc 'Get the currently authenticated user' do
success Entities::UserPublic
end
get feature_category: :users do
get feature_category: :users, urgency: :medium do
entity =
if current_user.admin?
Entities::UserWithAdmin

View File

@ -8,8 +8,7 @@ module Banzai
IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set
def call
return doc unless context[:project]
return doc unless Feature.enabled?(:custom_emoji, context[:project])
return doc unless resource_parent
doc.xpath('descendant-or-self::text()').each do |node|
content = node.to_html
@ -50,12 +49,12 @@ module Banzai
def has_custom_emoji?
strong_memoize(:has_custom_emoji) do
namespace&.custom_emoji&.any?
CustomEmoji.for_resource(resource_parent).any?
end
end
def namespace
context[:project].namespace.root_ancestor
def resource_parent
context[:project] || context[:group]
end
def custom_emoji_candidates
@ -63,7 +62,8 @@ module Banzai
end
def all_custom_emoji
@all_custom_emoji ||= namespace.custom_emoji.by_name(custom_emoji_candidates).index_by(&:name)
@all_custom_emoji ||=
CustomEmoji.for_resource(resource_parent).by_name(custom_emoji_candidates).index_by(&:name)
end
end
end

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
# rubocop: disable CodeReuse/ActiveRecord
class EventFilter
include Gitlab::Utils::StrongMemoize
@ -24,7 +25,6 @@ class EventFilter
filter == key.to_s
end
# rubocop: disable CodeReuse/ActiveRecord
def apply_filter(events)
case filter
when PUSH
@ -34,9 +34,9 @@ class EventFilter
when COMMENTS
events.commented_action
when TEAM
events.where(action: [:joined, :left, :expired])
events.where(action: Event::TEAM_ACTIONS)
when ISSUE
events.where(action: [:created, :updated, :closed, :reopened], target_type: 'Issue')
events.where(action: Event::ISSUE_ACTIONS, target_type: 'Issue')
when WIKI
wiki_events(events)
when DESIGNS
@ -45,10 +45,157 @@ class EventFilter
events
end
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable Metrics/CyclomaticComplexity
# This method build specialized in-operator optimized queries based on different
# filter parameters. All queries will benefit from the index covering the following columns:
# author_id target_type action id
#
# More context: https://docs.gitlab.com/ee/development/database/efficient_in_operator_queries.html#the-inoperatoroptimization-module
def in_operator_query_builder_params(user_ids)
case filter
when ALL
in_operator_params(array_scope_ids: user_ids)
when PUSH
# Here we need to add an order hint column to force the correct index usage.
# Without the order hint, the following conditions will use the `index_events_on_author_id_and_id`
# index which is not as efficient as the `index_events_for_followed_users` index.
# > target_type IS NULL AND action = 5 AND author_id = X ORDER BY id DESC
#
# The order hint adds an extra order by column which doesn't affect the result but forces the planner
# to use the correct index:
# > target_type IS NULL AND action = 5 AND author_id = X ORDER BY target_type DESC, id DESC
in_operator_params(
array_scope_ids: user_ids,
scope: Event.where(target_type: nil).pushed_action,
order_hint_column: :target_type
)
when MERGED
in_operator_params(
array_scope_ids: user_ids,
scope: Event.where(target_type: MergeRequest.to_s).merged_action
)
when COMMENTS
in_operator_params(
array_scope_ids: user_ids,
scope: Event.commented_action,
in_column: :target_type,
in_values: [Note, *Note.descendants].map(&:name) # To make the query efficient we need to list all Note classes
)
when TEAM
in_operator_params(
array_scope_ids: user_ids,
scope: Event.where(target_type: nil),
order_hint_column: :target_type,
in_column: :action,
in_values: Event.actions.values_at(*Event::TEAM_ACTIONS)
)
when ISSUE
in_operator_params(
array_scope_ids: user_ids,
scope: Event.where(target_type: Issue.name),
in_column: :action,
in_values: Event.actions.values_at(*Event::ISSUE_ACTIONS)
)
when WIKI
in_operator_params(
array_scope_ids: user_ids,
scope: Event.for_wiki_page,
in_column: :action,
in_values: Event.actions.values_at(*Event::WIKI_ACTIONS)
)
when DESIGNS
in_operator_params(
array_scope_ids: user_ids,
scope: Event.for_design,
in_column: :action,
in_values: Event.actions.values_at(*Event::DESIGN_ACTIONS)
)
else
in_operator_params(array_scope_ids: user_ids)
end
end
# rubocop: enable Metrics/CyclomaticComplexity
private
def in_operator_params(array_scope_ids:, scope: nil, in_column: nil, in_values: nil, order_hint_column: nil)
base_scope = Event.all
base_scope = base_scope.merge(scope) if scope
order = { id: :desc }
finder_query = -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
if order_hint_column.present?
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: order_hint_column,
order_expression: Event.arel_table[order_hint_column].desc,
nullable: :nulls_last,
distinct: false
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: :id,
order_expression: Event.arel_table[:id].desc
)
])
finder_query = -> (_order_hint, id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
end
base_scope = base_scope.reorder(order)
array_params = in_operator_array_params(
array_scope_ids: array_scope_ids,
scope: base_scope,
in_column: in_column,
in_values: in_values
)
array_params.merge(
scope: base_scope,
finder_query: finder_query
)
end
# This method builds the array_ parameters
# without in_column parameter: uses one IN filter: author_id
# with in_column: two IN filters: author_id, (target_type OR action)
def in_operator_array_params(scope:, array_scope_ids:, in_column: nil, in_values: nil)
if in_column
# Builds Carthesian product of the in_values and the array_scope_ids (in this case: user_ids).
# The process is described here: https://docs.gitlab.com/ee/development/database/efficient_in_operator_queries.html#multiple-in-queries
# VALUES ((array_scope_ids[0], in_values[0]), (array_scope_ids[1], in_values[0]) ...)
cartesian = array_scope_ids.product(in_values)
user_with_column_list = Arel::Nodes::ValuesList.new(cartesian)
as = "array_ids(id, #{Event.connection.quote_column_name(in_column)})"
from = Arel::Nodes::Grouping.new(user_with_column_list).as(as)
{
array_scope: User.select(:id, in_column).from(from),
array_mapping_scope: -> (author_id_expression, in_column_expression) do
Event
.merge(scope)
.where(Event.arel_table[:author_id].eq(author_id_expression))
.where(Event.arel_table[in_column].eq(in_column_expression))
end
}
else
# Builds a simple query to represent the array_scope_ids
# VALUES ((array_scope_ids[0]), (array_scope_ids[2])...)
array_ids_list = Arel::Nodes::ValuesList.new(array_scope_ids.map { |id| [id] })
from = Arel::Nodes::Grouping.new(array_ids_list).as('array_ids(id)')
{
array_scope: User.select(:id).from(from),
array_mapping_scope: -> (author_id_expression) do
Event
.merge(scope)
.where(Event.arel_table[:author_id].eq(author_id_expression))
end
}
end
end
def wiki_events(events)
events.for_wiki_page
end
@ -61,5 +208,6 @@ class EventFilter
[ALL, PUSH, MERGED, ISSUE, COMMENTS, TEAM, WIKI, DESIGNS]
end
end
# rubocop: enable CodeReuse/ActiveRecord
EventFilter.prepend_mod_with('EventFilter')

View File

@ -692,6 +692,8 @@ module Gitlab
# batch_column_name - option for tables without a primary key, in this case
# another unique integer column can be used. Example: :user_id
def undo_cleanup_concurrent_column_type_change(table, column, old_type, type_cast_function: nil, batch_column_name: :id, limit: nil)
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.require_ddl_mode!
temp_column = "#{column}_for_type_change"
# Using a descriptive name that includes orinal column's name risks
@ -1639,7 +1641,9 @@ into similar problems in the future (e.g. when new tables are created).
old_value = Arel::Nodes::NamedFunction.new(type_cast_function, [old_value])
end
update_column_in_batches(table, new, old_value, batch_column_name: batch_column_name)
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do
update_column_in_batches(table, new, old_value, batch_column_name: batch_column_name)
end
add_not_null_constraint(table, new) unless old_col.null

View File

@ -134,6 +134,8 @@ module Gitlab
# batch_column_name - option is for tables without primary key, in this
# case another unique integer column can be used. Example: :user_id
def rename_column_concurrently(table, old_column, new_column, type: nil, batch_column_name: :id)
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.require_ddl_mode!
setup_renamed_column(__callee__, table, old_column, new_column, type, batch_column_name)
with_lock_retries do
@ -181,6 +183,8 @@ module Gitlab
# case another unique integer column can be used. Example: :user_id
#
def undo_cleanup_concurrent_column_rename(table, old_column, new_column, type: nil, batch_column_name: :id)
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.require_ddl_mode!
setup_renamed_column(__callee__, table, new_column, old_column, type, batch_column_name)
with_lock_retries do

View File

@ -120,7 +120,7 @@ module Gitlab
.from(array_cte)
.join(Arel.sql("LEFT JOIN LATERAL (#{initial_keyset_query.to_sql}) #{table_name} ON TRUE"))
order_by_columns.each { |column| q.where(column.column_expression.not_eq(nil)) }
order_by_columns.each { |c| q.where(c.column_expression.not_eq(nil)) unless c.column.nullable? }
q.as('array_scope_lateral_query')
end
@ -200,7 +200,7 @@ module Gitlab
.project([*order_by_columns.original_column_names_as_arel_string, Arel.sql('position')])
.from("UNNEST(#{list(order_by_columns.array_aggregated_column_names)}) WITH ORDINALITY AS u(#{list(order_by_columns.original_column_names)}, position)")
order_by_columns.each { |column| q.where(Arel.sql(column.original_column_name).not_eq(nil)) } # ignore rows where all columns are NULL
order_by_columns.each { |c| q.where(Arel.sql(c.original_column_name).not_eq(nil)) unless c.column.nullable? } # ignore rows where all columns are NULL
q.order(Arel.sql(order_by_without_table_references)).take(1)
end

View File

@ -59,6 +59,11 @@ FactoryBot.define do
target { design }
end
factory :design_updated_event, traits: [:has_design] do
action { :updated }
target { design }
end
factory :project_created_event do
project factory: :project
action { :created }

View File

@ -8,9 +8,9 @@ RSpec.describe UserRecentEventsFinder do
let_it_be(:private_project) { create(:project, :private, creator: project_owner) }
let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) }
let_it_be(:public_project) { create(:project, :public, creator: project_owner) }
let!(:private_event) { create(:event, project: private_project, author: project_owner) }
let!(:internal_event) { create(:event, project: internal_project, author: project_owner) }
let!(:public_event) { create(:event, project: public_project, author: project_owner) }
let_it_be(:private_event) { create(:event, project: private_project, author: project_owner) }
let_it_be(:internal_event) { create(:event, project: internal_project, author: project_owner) }
let_it_be(:public_event) { create(:event, project: public_project, author: project_owner) }
let_it_be(:issue) { create(:issue, project: public_project) }
let(:limit) { nil }
@ -18,210 +18,266 @@ RSpec.describe UserRecentEventsFinder do
subject(:finder) { described_class.new(current_user, project_owner, nil, params) }
describe '#execute' do
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
shared_examples 'UserRecentEventsFinder examples' do
describe '#execute' do
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
end
end
end
context 'when profile is private' do
it 'returns no event' do
context 'when profile is private' do
it 'returns no event' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
expect(finder.execute).to be_empty
end
end
it 'does not include the events if the user cannot read cross project' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
expect(finder.execute).to be_empty
end
end
it 'does not include the events if the user cannot read cross project' do
allow(Ability).to receive(:allowed?).and_call_original
expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
context 'events from multiple users' do
let_it_be(:second_user, reload: true) { create(:user) }
let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
expect(finder.execute).to be_empty
end
let_it_be(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
let_it_be(:public_project_second_user) { create(:project, :public, creator: second_user) }
let_it_be(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
let_it_be(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
let_it_be(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
context 'events from multiple users' do
let_it_be(:second_user, reload: true) { create(:user) }
let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
it 'includes events from all users', :aggregate_failures do
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
let(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
let(:public_project_second_user) { create(:project, :public, creator: second_user) }
let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
expect(events.size).to eq(6)
end
it 'includes events from all users', :aggregate_failures do
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
context 'selected events' do
using RSpec::Parameterized::TableSyntax
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
expect(events.size).to eq(6)
let_it_be(:push_event1) { create(:push_event, project: public_project, author: project_owner) }
let_it_be(:push_event2) { create(:push_event, project: public_project_second_user, author: second_user) }
let_it_be(:merge_event1) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project, author: project_owner) }
let_it_be(:merge_event2) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project_second_user, author: second_user) }
let_it_be(:comment_event1) { create(:event, :commented, target_type: Note.to_s, project: public_project, author: project_owner) }
let_it_be(:comment_event2) { create(:event, :commented, target_type: DiffNote.to_s, project: public_project, author: project_owner) }
let_it_be(:comment_event3) { create(:event, :commented, target_type: DiscussionNote.to_s, project: public_project_second_user, author: second_user) }
let_it_be(:issue_event1) { create(:event, :created, project: public_project, target: issue, author: project_owner) }
let_it_be(:issue_event2) { create(:event, :updated, project: public_project, target: issue, author: project_owner) }
let_it_be(:issue_event3) { create(:event, :closed, project: public_project_second_user, target: issue, author: second_user) }
let_it_be(:wiki_event1) { create(:wiki_page_event, project: public_project, author: project_owner) }
let_it_be(:wiki_event2) { create(:wiki_page_event, project: public_project_second_user, author: second_user) }
let_it_be(:design_event1) { create(:design_event, project: public_project, author: project_owner) }
let_it_be(:design_event2) { create(:design_updated_event, project: public_project_second_user, author: second_user) }
where(:event_filter, :ordered_expected_events) do
EventFilter.new(EventFilter::PUSH) | lazy { [push_event1, push_event2] }
EventFilter.new(EventFilter::MERGED) | lazy { [merge_event1, merge_event2] }
EventFilter.new(EventFilter::COMMENTS) | lazy { [comment_event1, comment_event2, comment_event3] }
EventFilter.new(EventFilter::TEAM) | lazy { [private_event, internal_event, public_event, private_event_second_user, internal_event_second_user, public_event_second_user] }
EventFilter.new(EventFilter::ISSUE) | lazy { [issue_event1, issue_event2, issue_event3] }
EventFilter.new(EventFilter::WIKI) | lazy { [wiki_event1, wiki_event2] }
EventFilter.new(EventFilter::DESIGNS) | lazy { [design_event1, design_event2] }
end
with_them do
it 'only returns selected events from all users (id DESC)' do
events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute
expect(events).to eq(ordered_expected_events.reverse)
end
end
end
it 'does not include events from users with private profile', :aggregate_failures do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
expect(events).to contain_exactly(private_event, internal_event, public_event)
end
context 'with pagination params' do
using RSpec::Parameterized::TableSyntax
where(:limit, :offset, :ordered_expected_events) do
nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] }
2 | nil | lazy { [public_event_second_user, internal_event_second_user] }
nil | 4 | lazy { [internal_event, private_event] }
2 | 2 | lazy { [private_event_second_user, public_event] }
end
with_them do
let(:params) { { limit: limit, offset: offset }.compact }
it 'returns paginated events sorted by id (DESC)' do
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
expect(events).to eq(ordered_expected_events)
end
end
end
end
context 'selected events' do
let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
let!(:push_event_second_user) { create(:push_event, project: public_project_second_user, author: second_user) }
context 'filter activity events' do
let_it_be(:push_event) { create(:push_event, project: public_project, author: project_owner) }
let_it_be(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
let_it_be(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
let_it_be(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
let_it_be(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
let_it_be(:design_event) { create(:design_event, project: public_project, author: project_owner) }
let_it_be(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
it 'only includes selected events (PUSH) from all users', :aggregate_failures do
it 'includes all events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ALL)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
expect(events.size).to eq(10)
end
context 'when unknown filter is given' do
it 'includes returns all events', :aggregate_failures do
event_filter = EventFilter.new('unknown')
allow(event_filter).to receive(:filter).and_return('unknown')
events = described_class.new(current_user, [project_owner], event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
expect(events.size).to eq(10)
end
end
it 'only includes push events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::PUSH)
events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to contain_exactly(push_event, push_event_second_user)
expect(events).to include(push_event)
expect(events.size).to eq(1)
end
it 'only includes merge events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::MERGED)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(merge_event)
expect(events.size).to eq(1)
end
it 'only includes issue events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ISSUE)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(issue_event)
expect(events.size).to eq(1)
end
it 'only includes comments events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::COMMENTS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(comment_event)
expect(events.size).to eq(1)
end
it 'only includes wiki events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::WIKI)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(wiki_event)
expect(events.size).to eq(1)
end
it 'only includes design events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::DESIGNS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(design_event)
expect(events.size).to eq(1)
end
it 'only includes team events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::TEAM)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event, team_event)
expect(events.size).to eq(4)
end
end
it 'does not include events from users with private profile', :aggregate_failures do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
describe 'issue activity events' do
let(:issue) { create(:issue, project: public_project) }
let(:note) { create(:note_on_issue, noteable: issue, project: public_project) }
let!(:event_a) { create(:event, :commented, target: note, author: project_owner) }
let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) }
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
it 'includes all issue related events', :aggregate_failures do
events = finder.execute
expect(events).to contain_exactly(private_event, internal_event, public_event)
expect(events).to include(event_a)
expect(events).to include(event_b)
end
end
context 'with pagination params' do
using RSpec::Parameterized::TableSyntax
where(:limit, :offset, :ordered_expected_events) do
nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] }
2 | nil | lazy { [public_event_second_user, internal_event_second_user] }
nil | 4 | lazy { [internal_event, private_event] }
2 | 2 | lazy { [private_event_second_user, public_event] }
context 'limits' do
before do
stub_const("#{described_class}::DEFAULT_LIMIT", 1)
stub_const("#{described_class}::MAX_LIMIT", 3)
end
with_them do
let(:params) { { limit: limit, offset: offset }.compact }
context 'when limit is not set' do
it 'returns events limited to DEFAULT_LIMIT' do
expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT)
end
end
it 'returns paginated events sorted by id (DESC)' do
events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
context 'when limit is set' do
let(:limit) { 2 }
expect(events).to eq(ordered_expected_events)
it 'returns events limited to specified limit' do
expect(finder.execute.size).to eq(limit)
end
end
context 'when limit is set to a number that exceeds maximum limit' do
let(:limit) { 4 }
before do
create(:event, project: public_project, author: project_owner)
end
it 'returns events limited to MAX_LIMIT' do
expect(finder.execute.size).to eq(described_class::MAX_LIMIT)
end
end
end
end
end
context 'filter activity events' do
let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
let!(:design_event) { create(:design_event, project: public_project, author: project_owner) }
let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
it 'includes all events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ALL)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event)
expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
expect(events.size).to eq(10)
end
it 'only includes push events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::PUSH)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(push_event)
expect(events.size).to eq(1)
end
it 'only includes merge events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::MERGED)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(merge_event)
expect(events.size).to eq(1)
end
it 'only includes issue events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::ISSUE)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(issue_event)
expect(events.size).to eq(1)
end
it 'only includes comments events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::COMMENTS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(comment_event)
expect(events.size).to eq(1)
end
it 'only includes wiki events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::WIKI)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(wiki_event)
expect(events.size).to eq(1)
end
it 'only includes design events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::DESIGNS)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(design_event)
expect(events.size).to eq(1)
end
it 'only includes team events', :aggregate_failures do
event_filter = EventFilter.new(EventFilter::TEAM)
events = described_class.new(current_user, project_owner, event_filter, params).execute
expect(events).to include(private_event, internal_event, public_event, team_event)
expect(events.size).to eq(4)
end
context 'when the optimized_followed_users_queries FF is on' do
before do
stub_feature_flags(optimized_followed_users_queries: true)
end
describe 'issue activity events' do
let(:issue) { create(:issue, project: public_project) }
let(:note) { create(:note_on_issue, noteable: issue, project: public_project) }
let!(:event_a) { create(:event, :commented, target: note, author: project_owner) }
let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) }
it_behaves_like 'UserRecentEventsFinder examples'
end
it 'includes all issue related events', :aggregate_failures do
events = finder.execute
expect(events).to include(event_a)
expect(events).to include(event_b)
end
context 'when the optimized_followed_users_queries FF is off' do
before do
stub_feature_flags(optimized_followed_users_queries: false)
end
context 'limits' do
before do
stub_const("#{described_class}::DEFAULT_LIMIT", 1)
stub_const("#{described_class}::MAX_LIMIT", 3)
end
context 'when limit is not set' do
it 'returns events limited to DEFAULT_LIMIT' do
expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT)
end
end
context 'when limit is set' do
let(:limit) { 2 }
it 'returns events limited to specified limit' do
expect(finder.execute.size).to eq(limit)
end
end
context 'when limit is set to a number that exceeds maximum limit' do
let(:limit) { 4 }
before do
create(:event, project: public_project, author: project_owner)
end
it 'returns events limited to MAX_LIMIT' do
expect(finder.execute.size).to eq(described_class::MAX_LIMIT)
end
end
end
it_behaves_like 'UserRecentEventsFinder examples'
end
end

View File

@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { format } from 'timeago.js';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
@ -44,10 +45,16 @@ describe('Environment item', () => {
const findAutoStop = () => wrapper.find('.js-auto-stop');
const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]');
const findLastDeployment = () => wrapper.find('[data-testid="environment-deployment-id-cell"]');
const findUpcomingDeploymentContent = () =>
wrapper.find('[data-testid="upcoming-deployment-content"]');
const findUpcomingDeploymentStatusLink = () =>
wrapper.find('[data-testid="upcoming-deployment-status-link"]');
const findLastDeploymentAvatarLink = () => findLastDeployment().findComponent(GlAvatarLink);
const findLastDeploymentAvatar = () => findLastDeployment().findComponent(GlAvatar);
const findUpcomingDeploymentAvatarLink = () =>
findUpcomingDeployment().findComponent(GlAvatarLink);
const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar);
afterEach(() => {
wrapper.destroy();
@ -79,9 +86,19 @@ describe('Environment item', () => {
describe('With user information', () => {
it('should render user avatar with link to profile', () => {
expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual(
environment.last_deployment.user.web_url,
);
const avatarLink = findLastDeploymentAvatarLink();
const avatar = findLastDeploymentAvatar();
const { username, avatar_url, web_url } = environment.last_deployment.user;
expect(avatarLink.attributes('href')).toBe(web_url);
expect(avatar.props()).toMatchObject({
src: avatar_url,
entityName: username,
});
expect(avatar.attributes()).toMatchObject({
title: username,
alt: `${username}'s avatar`,
});
});
});
@ -108,9 +125,16 @@ describe('Environment item', () => {
describe('When the envionment has an upcoming deployment', () => {
describe('When the upcoming deployment has a deployable', () => {
it('should render the build ID and user', () => {
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
'#27 by upcoming-username',
);
const avatarLink = findUpcomingDeploymentAvatarLink();
const avatar = findUpcomingDeploymentAvatar();
const { username, avatar_url, web_url } = environment.upcoming_deployment.user;
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by');
expect(avatarLink.attributes('href')).toBe(web_url);
expect(avatar.props()).toMatchObject({
src: avatar_url,
entityName: username,
});
});
it('should render a status icon with a link and tooltip', () => {
@ -139,10 +163,17 @@ describe('Environment item', () => {
});
});
it('should still renders the build ID and user', () => {
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
'#27 by upcoming-username',
);
it('should still render the build ID and user avatar', () => {
const avatarLink = findUpcomingDeploymentAvatarLink();
const avatar = findUpcomingDeploymentAvatar();
const { username, avatar_url, web_url } = environment.upcoming_deployment.user;
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by');
expect(avatarLink.attributes('href')).toBe(web_url);
expect(avatar.props()).toMatchObject({
src: avatar_url,
entityName: username,
});
});
it('should not render the status icon', () => {
@ -383,7 +414,7 @@ describe('Environment item', () => {
});
it('should hide non-folder properties', () => {
expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false);
expect(findLastDeployment().exists()).toBe(false);
expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false);
});
});

View File

@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import reportSection from '~/reports/components/report_section.vue';
describe('Report section', () => {
@ -9,6 +10,7 @@ describe('Report section', () => {
let wrapper;
const ReportSection = Vue.extend(reportSection);
const findCollapseButton = () => wrapper.findByTestId('report-section-expand-button');
const findPopover = () => wrapper.findComponent(HelpPopover);
const resolvedIssues = [
{
@ -269,4 +271,33 @@ describe('Report section', () => {
expect(vm.$el.textContent.trim()).not.toContain('This is a success');
});
});
describe('help popover', () => {
describe('when popover options are defined', () => {
const options = {
title: 'foo',
content: 'bar',
};
beforeEach(() => {
createComponent({
popoverOptions: options,
});
});
it('popover is shown with options', () => {
expect(findPopover().props('options')).toEqual(options);
});
});
describe('when popover options are not defined', () => {
beforeEach(() => {
createComponent({ popoverOptions: {} });
});
it('popover is not shown', () => {
expect(findPopover().exists()).toBe(false);
});
});
});
});

View File

@ -96,6 +96,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
expect(new_record_1.reload).to have_attributes(status: 1, original: 'updated', renamed: 'updated')
expect(new_record_2.reload).to have_attributes(status: 1, original: nil, renamed: nil)
end
it 'requires the helper to run in ddl mode' do
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
migration.public_send(operation, :_test_table, :original, :renamed)
end
end
describe '#rename_column_concurrently' do

View File

@ -1390,6 +1390,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
it 'reverses the operations of cleanup_concurrent_column_type_change' do
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
expect(model).to receive(:check_trigger_permissions!).with(:users)
expect(model).to receive(:create_column_from).with(
@ -1415,6 +1417,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
it 'passes the type_cast_function, batch_column_name and limit' do
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true)
expect(model).to receive(:check_trigger_permissions!).with(:users)

View File

@ -9,7 +9,7 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
end
allow(Gitlab::RequestEndpoints).to receive(:all_api_endpoints).and_return([api_route])
allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'show']])
allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'index']])
allow(Gitlab::Graphql::KnownOperations).to receive(:default).and_return(Gitlab::Graphql::KnownOperations.new(%w(foo bar)))
end
@ -22,7 +22,7 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
request_urgency: :default
},
{
endpoint_id: "ProjectsController#show",
endpoint_id: "ProjectsController#index",
feature_category: :projects,
request_urgency: :default
}

View File

@ -24,12 +24,12 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
let_it_be(:issues) do
[
create(:issue, project: project_1, created_at: three_weeks_ago, relative_position: 5),
create(:issue, project: project_1, created_at: two_weeks_ago),
create(:issue, project: project_1, created_at: two_weeks_ago, relative_position: nil),
create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: 15),
create(:issue, project: project_2, created_at: two_weeks_ago),
create(:issue, project: project_3, created_at: four_weeks_ago),
create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: nil),
create(:issue, project: project_3, created_at: four_weeks_ago, relative_position: nil),
create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10),
create(:issue, project: project_5, created_at: four_weeks_ago)
create(:issue, project: project_5, created_at: four_weeks_ago, relative_position: nil)
]
end
@ -155,6 +155,31 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
it_behaves_like 'correct ordering examples'
end
context 'with condition "relative_position IS NULL"' do
let(:base_scope) { Issue.where(relative_position: nil) }
let(:scope) { base_scope.order(order) }
let(:in_operator_optimization_options) do
{
array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
array_mapping_scope: -> (id_expression) { Issue.merge(base_scope.dup).where(Issue.arel_table[:project_id].eq(id_expression)) },
finder_query: -> (_relative_position_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
}
end
context 'when iterating records one by one' do
let(:batch_size) { 1 }
it_behaves_like 'correct ordering examples'
end
context 'when iterating records with LIMIT 3' do
let(:batch_size) { 3 }
it_behaves_like 'correct ordering examples'
end
end
end
context 'when ordering by issues.created_at DESC, issues.id ASC' do

View File

@ -59,17 +59,41 @@ RSpec.describe AwardEmoji do
end
end
it 'accepts custom emoji' do
user = create(:user)
group = create(:group)
group.add_maintainer(user)
context 'custom emoji' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:emoji) { create(:custom_emoji, name: 'partyparrot', namespace: group) }
project = create(:project, namespace: group)
issue = create(:issue, project: project)
emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name)
before do
group.add_maintainer(user)
end
expect(new_award).to be_valid
%i[issue merge_request note_on_issue snippet].each do |awardable_type|
let_it_be(:project) { create(:project, namespace: group) }
let(:awardable) { create(awardable_type, project: project) }
it "is accepted on #{awardable_type}" do
new_award = build(:award_emoji, user: user, awardable: awardable, name: emoji.name)
expect(new_award).to be_valid
end
end
it 'is accepted on subgroup issue' do
subgroup = create(:group, parent: group)
project = create(:project, namespace: subgroup)
issue = create(:issue, project: project)
new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name)
expect(new_award).to be_valid
end
it 'is not supported on personal snippet (yet)' do
snippet = create(:personal_snippet)
new_award = build(:award_emoji, user: snippet.author, awardable: snippet, name: 'null')
expect(new_award).not_to be_valid
end
end
end
@ -223,4 +247,47 @@ RSpec.describe AwardEmoji do
end
end
end
describe '#url' do
let_it_be(:custom_emoji) { create(:custom_emoji) }
let_it_be(:project) { create(:project, namespace: custom_emoji.group) }
let_it_be(:issue) { create(:issue, project: project) }
def build_award(name)
build(:award_emoji, awardable: issue, name: name)
end
it 'is nil for built-in emoji' do
new_award = build_award('tada')
count = ActiveRecord::QueryRecorder.new do
expect(new_award.url).to be_nil
end.count
expect(count).to be_zero
end
it 'is nil for unrecognized emoji' do
new_award = build_award('null')
expect(new_award.url).to be_nil
end
it 'is set for custom emoji' do
new_award = build_award(custom_emoji.name)
expect(new_award.url).to eq(custom_emoji.url)
end
context 'feature flag disabled' do
before do
stub_feature_flags(custom_emoji: false)
end
it 'does not query' do
new_award = build_award(custom_emoji.name)
expect(ActiveRecord::QueryRecorder.new { new_award.url }.count).to be_zero
end
end
end
end