Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-10-04 09:09:18 +00:00
parent c99b40d5a7
commit 0d8bcdf77d
57 changed files with 841 additions and 409 deletions

View File

@ -785,3 +785,21 @@ Gemspec/AvoidExecutingGit:
Lint/BinaryOperatorWithIdenticalOperands:
Exclude:
- '{,ee/,qa/}spec/**/*_{spec,shared_examples,shared_context}.rb'
Cop/SidekiqRedisCall:
Enabled: true
Exclude:
- '{,ee/,jh/}spec/**/*'
- 'lib/gitlab/database/migration_helpers.rb'
- 'lib/gitlab/sidekiq_migrate_jobs.rb'
- 'lib/gitlab/sidekiq_versioning.rb'
Cop/RedisQueueUsage:
Enabled: true
Exclude:
- '{,ee/,jh/}spec/**/*'
- 'config/initializers/sidekiq.rb'
- 'lib/gitlab/instrumentation/redis.rb'
- 'lib/gitlab/redis.rb'
- 'lib/system_check/app/redis_version_check.rb'
- 'lib/gitlab/mail_room.rb'

View File

@ -121,7 +121,7 @@ export default class MilestoneSelect {
title: __('Started'),
});
}
if (extraOptions.length) {
if (extraOptions.length && data.length) {
extraOptions.push({ type: 'divider' });
}

View File

@ -1,3 +0,0 @@
import UsersSelect from '~/users_select';
new UsersSelect(); // eslint-disable-line no-new

View File

@ -30,7 +30,12 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import {
ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
I18N_FETCH_ERROR,
FILTER_CSS_CLASSES,
} from '../constants';
import { captureException } from '../sentry_utils';
export default {
@ -167,6 +172,7 @@ export default {
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
FILTER_CSS_CLASSES,
};
</script>
<template>
@ -195,6 +201,7 @@ export default {
<runner-filtered-search-bar
v-model="search"
:class="$options.FILTER_CSS_CLASSES"
:tokens="searchTokens"
:namespace="$options.filteredSearchNamespace"
/>

View File

@ -85,7 +85,6 @@ export default {
</script>
<template>
<filtered-search
class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1"
v-bind="$attrs"
:namespace="namespace"
recent-searches-storage-key="runners-search"

View File

@ -0,0 +1,42 @@
<script>
import { GlToggle } from '@gitlab/ui';
import {
I18N_SHOW_ONLY_INHERITED,
MEMBERSHIP_DESCENDANTS,
MEMBERSHIP_ALL_AVAILABLE,
} from '../constants';
export default {
components: {
GlToggle,
},
props: {
value: {
type: String,
default: MEMBERSHIP_DESCENDANTS,
required: false,
},
},
computed: {
toggle() {
return this.value === MEMBERSHIP_DESCENDANTS;
},
},
methods: {
onChange(value) {
this.$emit('input', value ? MEMBERSHIP_DESCENDANTS : MEMBERSHIP_ALL_AVAILABLE);
},
},
I18N_SHOW_ONLY_INHERITED,
};
</script>
<template>
<gl-toggle
data-testid="runner-membership-toggle"
:value="toggle"
:label="$options.I18N_SHOW_ONLY_INHERITED"
label-position="left"
@change="onChange"
/>
</template>

View File

@ -11,6 +11,9 @@ export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
export const FILTER_CSS_CLASSES =
'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1';
// Type
export const I18N_ALL_TYPES = s__('Runners|All');
@ -85,6 +88,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
// Runner details
@ -110,6 +114,7 @@ export const PARAM_KEY_PAUSED = 'paused';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_TAG = 'tag';
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_MEMBERSHIP = 'membership';
export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_AFTER = 'after';
@ -142,6 +147,13 @@ export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;
// CiRunnerMembershipFilter
export const MEMBERSHIP_DESCENDANTS = 'DESCENDANTS';
export const MEMBERSHIP_ALL_AVAILABLE = 'ALL_AVAILABLE';
export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS;
// Local storage namespaces
export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';

View File

@ -2,6 +2,7 @@
query getGroupRunners(
$groupFullPath: ID!
$membership: CiRunnerMembershipFilter
$before: String
$after: String
$first: Int
@ -15,7 +16,7 @@ query getGroupRunners(
group(fullPath: $groupFullPath) {
id # Apollo required
runners(
membership: DESCENDANTS
membership: $membership
before: $before
after: $after
first: $first

View File

@ -1,5 +1,6 @@
query getGroupRunnersCount(
$groupFullPath: ID!
$membership: CiRunnerMembershipFilter
$paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
@ -9,7 +10,7 @@ query getGroupRunnersCount(
group(fullPath: $groupFullPath) {
id # Apollo required
runners(
membership: DESCENDANTS
membership: $membership
paused: $paused
status: $status
type: $type

View File

@ -10,6 +10,7 @@ import {
fromSearchToVariables,
isSearchFiltered,
} from 'ee_else_ce/runner/runner_search_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
@ -22,6 +23,7 @@ import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import RunnerMembershipToggle from '../components/runner_membership_toggle.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
@ -30,6 +32,7 @@ import {
GROUP_TYPE,
PROJECT_TYPE,
I18N_FETCH_ERROR,
FILTER_CSS_CLASSES,
} from '../constants';
import { captureException } from '../sentry_utils';
@ -43,11 +46,13 @@ export default {
RunnerList,
RunnerListEmptyState,
RunnerName,
RunnerMembershipToggle,
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
},
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
registrationToken: {
@ -135,6 +140,11 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
shouldRenderAllAvailableToggle() {
// Feature flag for `runners_finder_all_available`
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/374525
return this.glFeatures?.runnersFinderAllAvailable;
},
},
watch: {
search: {
@ -176,6 +186,7 @@ export default {
},
TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE,
FILTER_CSS_CLASSES,
};
</script>
@ -204,11 +215,22 @@ export default {
/>
</div>
<runner-filtered-search-bar
v-model="search"
:tokens="searchTokens"
:namespace="filteredSearchNamespace"
/>
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
:class="$options.FILTER_CSS_CLASSES"
>
<runner-filtered-search-bar
v-model="search"
:tokens="searchTokens"
:namespace="filteredSearchNamespace"
class="gl-flex-grow-1 gl-align-self-stretch"
/>
<runner-membership-toggle
v-if="shouldRenderAllAvailableToggle"
v-model="search.membership"
class="gl-align-self-end gl-md-align-self-center"
/>
</div>
<runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" />

View File

@ -13,10 +13,12 @@ import {
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
PARAM_KEY_SEARCH,
PARAM_KEY_MEMBERSHIP,
PARAM_KEY_SORT,
PARAM_KEY_AFTER,
PARAM_KEY_BEFORE,
DEFAULT_SORT,
DEFAULT_MEMBERSHIP,
RUNNER_PAGE_SIZE,
} from './constants';
import { getPaginationVariables } from './utils';
@ -57,9 +59,10 @@ import { getPaginationVariables } from './utils';
* @param {Object} search
* @returns {boolean} True if the value follows the search format.
*/
export const searchValidator = ({ runnerType, filters, sort }) => {
export const searchValidator = ({ runnerType, membership, filters, sort }) => {
return (
(runnerType === null || typeof runnerType === 'string') &&
(membership === null || typeof membership === 'string') &&
Array.isArray(filters) &&
typeof sort === 'string'
);
@ -140,9 +143,11 @@ export const updateOutdatedUrl = (url = window.location.href) => {
export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null;
const membership = params[PARAM_KEY_MEMBERSHIP]?.[0] || null;
return {
runnerType,
membership: membership || DEFAULT_MEMBERSHIP,
filters: prepareTokens(
urlQueryToFilter(query, {
filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG],
@ -162,13 +167,14 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
* @returns {String} New URL for the page
*/
export const fromSearchToUrl = (
{ runnerType = null, filters = [], sort = null, pagination = {} },
{ runnerType = null, membership = null, filters = [], sort = null, pagination = {} },
url = window.location.href,
) => {
const filterParams = {
// Defaults
[PARAM_KEY_STATUS]: [],
[PARAM_KEY_RUNNER_TYPE]: [],
[PARAM_KEY_MEMBERSHIP]: [],
[PARAM_KEY_TAG]: [],
// Current filters
...filterToQueryObject(processFilters(filters), {
@ -180,6 +186,10 @@ export const fromSearchToUrl = (
filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType];
}
if (membership && membership !== DEFAULT_MEMBERSHIP) {
filterParams[PARAM_KEY_MEMBERSHIP] = [membership];
}
if (!filterParams[PARAM_KEY_SEARCH]) {
filterParams[PARAM_KEY_SEARCH] = null;
}
@ -203,6 +213,7 @@ export const fromSearchToUrl = (
*/
export const fromSearchToVariables = ({
runnerType = null,
membership = null,
filters = [],
sort = null,
pagination = {},
@ -226,6 +237,9 @@ export const fromSearchToVariables = ({
if (runnerType) {
filterVariables.type = runnerType;
}
if (membership) {
filterVariables.membership = membership;
}
if (sort) {
filterVariables.sort = sort;
}

View File

@ -60,17 +60,6 @@ class Admin::GroupsController < Admin::ApplicationController
end
end
def members_update
member_params = params.permit(:user_id, :access_level, :expires_at)
result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group, invite_source: 'admin-group-page')).execute
if result[:status] == :success
redirect_to [:admin, @group], notice: _('Users were successfully added.')
else
redirect_to [:admin, @group], alert: result[:message]
end
end
def destroy
Groups::DestroyService.new(@group, current_user).async_execute

View File

@ -5,6 +5,10 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
before_action do
push_frontend_feature_flag(:runners_finder_all_available, @group)
end
before_action only: [:show] do
push_frontend_feature_flag(:enforce_runner_token_expires_at)
end

View File

@ -237,3 +237,5 @@ module CacheMarkdownField
end
end
end
CacheMarkdownField.prepend_mod

View File

@ -102,21 +102,6 @@
= render 'shared/admin/admin_note'
- if can?(current_user, :admin_group_member, @group)
.card
.card-header
= _('Add user(s) to the group:')
.card-body.form-holder
%p.light
- help_link_open = '<strong><a href="%{help_url}">'.html_safe % { help_url: help_page_url("user/permissions") }
= html_escape(_('Read more about project permissions %{help_link_open}here%{help_link_close}')) % { help_link_open: help_link_open, help_link_close: '</a></strong>'.html_safe }
= form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do
%div
= users_select_tag(:user_id, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all)
.gl-mt-3
= select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
= button_tag _('Add users to group'), class: "gl-button btn btn-confirm"
= render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true
.card

View File

@ -11,10 +11,6 @@
placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone'), qa_selector: "issuable_milestone_dropdown", testid: "issuable-milestone-dropdown" } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
%li
= link_to new_project_milestone_path(project), title: _('New Milestone') do
= _('Create new')
%li
= link_to project_milestones_path(project) do
- if can? current_user, :admin_milestone, project

View File

@ -5,7 +5,7 @@
- return if requesters.empty?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c|
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c|
- c.header do
= _('Users requesting access to')
%strong= membership_source.name

View File

@ -70,11 +70,6 @@ InitializerConnections.with_disabled_database_connections do
Gitlab.ee do
resource :company, only: [:new, :create], controller: 'company'
# legacy - to be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/371996
get 'groups/new', to: redirect('users/sign_up/groups_projects/new')
get 'projects/new', to: redirect('users/sign_up/groups_projects/new')
resources :groups_projects, only: [:new, :create] do
collection do
post :import

View File

@ -69,8 +69,7 @@ To set up an external database, you can either:
Given you have a primary site set up on AWS EC2 that uses RDS.
You can now just create a read-only replica in a different region and the
replication process is managed by AWS. Make sure you've set Network ACL (Access Control List), Subnet, and
Security Group according to your needs, so the secondary Rails node(s) can access the database.
replication process is managed by AWS. Make sure you've set Network ACL (Access Control List), Subnet, and Security Group according to your needs, so the secondary Rails nodes can access the database.
The following instructions detail how to create a read-only replica for common
cloud providers:

View File

@ -143,7 +143,7 @@ We should likely position this as a push for GitLab workspaces and not talk abou
What are other distinct advantages of workspaces that could be shipped?
- Easier admin controls
- Easier administrator controls
- Better permission management
- Instance-like UX

View File

@ -357,7 +357,7 @@ hierarchy. Choosing a proper solution will require a thoughtful research.
1. Build application limits API in a way that it can be easily extracted to a separate service.
1. Build application limits definition in a way that is independent from the Rails application.
1. Build tooling that produce consistent behavior and results across programming languages.
1. Build the new framework in a way that we can extend to allow self-managed admins to customize limits.
1. Build the new framework in a way that we can extend to allow self-managed administrators to customize limits.
1. Maintain consistent features and behavior across SaaS and self-managed codebase.
1. Be mindful about a cognitive load added by the hierarchical limits, aim to reduce it.

View File

@ -19,7 +19,7 @@ actions performed across the application.
While any events could trigger an Audit Event, not all events should. In general, events that are not good candidates for audit events are:
- Not attributable to one specific user.
- Not of specific interest to an admin or owner persona.
- Not of specific interest to an administrator or owner persona.
- Are tracking information for product feature adoption.
- Are covered in the direction page's discussion on [what is not planned](https://about.gitlab.com/direction/manage/compliance/audit-events/#what-is-not-planned-right-now).

View File

@ -72,7 +72,7 @@ To guard your licensed feature:
```
1. Optional. If your global feature is also available to namespaces with a paid plan, combine two
feature identifiers to allow both admins and group users. For example:
feature identifiers to allow both administrators and group users. For example:
```ruby
License.feature_available?(:my_feature_name) || group.licensed_feature_available?(:my_feature_name_for_namespace) # Both admins and group members can see this EE feature

View File

@ -186,3 +186,9 @@ default weight, which is 1.
Each Sidekiq worker must be tested using RSpec, just like any other class. These
tests should be placed in `spec/workers`.
## Interacting with Sidekiq Redis
The application should minimise interaction with of any `Sidekiq.redis`. Directly interacting with `Sidekiq.redis` in generic logic should be abstracted to a [Sidekiq middleware](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/sidekiq_middleware) for re-use across teams. By decoupling application logic from Sidekiq's datastore, it allows for greater freedom when horizontally scaling the GitLab background processing setup.
Some exceptions to this rule would be migration-related logic or administration operations.

View File

@ -29,7 +29,7 @@ user doesn't need to take any additional action and can sign in as usual.
## How do we treat malicious sign-in attempts?
Users are not denied access if Arkose Protect considers they are malicious. However,
their risk score is exposed in the admin console so that we can make more informed decisions when it
their risk score is exposed in the administrator console so that we can make more informed decisions when it
comes to manually blocking users. When we decide to block a user, feedback is sent to ArkoseLabs to
improve their risk prediction model.

View File

@ -272,7 +272,7 @@ There are 4 users on local instance
### Use `mmctl` through a remote connection
For remote connections or local connections where the socket cannot be used,
create a non SSO user and give that user admin privileges. Those credentials
create a non SSO user and give that user administrator privileges. Those credentials
can then be used to authenticate `mmctl`:
```shell

View File

@ -8,7 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
## Description
The target website returns AspNet header(s) and version information of this website. By
The target website returns AspNet headers and version information of this website. By
exposing these values attackers may attempt to identify if the target software is vulnerable to known
vulnerabilities, or catalog known sites running particular versions to exploit in the future when a
vulnerability is identified in the particular version.

View File

@ -8,7 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
## Description
The target website returns AspNet header(s) along with version information of this website. By
The target website returns AspNet headers along with version information of this website. By
exposing these values attackers may attempt to identify if the target software is vulnerable to known
vulnerabilities. Or catalog known sites running particular versions to exploit in the future when a
vulnerability is identified in the particular version.

View File

@ -30,7 +30,7 @@ It is important that this SCIM `id` and SCIM `externalId` are configured to the
## How do I verify user's SAML NameId matches the SCIM externalId
Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/index.md#user-identities).
Administrators can use the Admin Area to [list SCIM identities for a user](../../admin_area/index.md#user-identities).
Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page.

View File

@ -26,7 +26,7 @@ you can use the [CI/CD workflow](../../clusters/agent/ci_cd_workflow.md).
This workflow uses an agent to connect to your cluster. The agent:
- Is not exposed to the internet.
- Does not require full cluster-admin access to GitLab.
- Does not require full [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) access to GitLab.
NOTE:
The certificate-based integration was used for popular GitLab features like

View File

@ -9,35 +9,35 @@ type: index, reference
## How it works
Suggested Reviewers is the first user-facing GitLab machine learning (ML) powered feature. It leverages a project's contribution graph to generate suggestions. This data already exists within GitLab including merge request metadata, source code files, and GitLab user account metadata.
Suggested Reviewers is the first user-facing GitLab machine learning (ML) powered feature. It leverages a project's contribution graph to generate suggestions. This data already exists within GitLab including merge request metadata, source code files, and GitLab user account metadata.
### Enabling the feature
When a Project Maintainer or Owner enables Suggested Reviewers in project settings GitLab kicks off a data extraction job for the project which leverages the Merge Request API to understand pattern of review including recency, domain experience, and frequency to suggest an appropriate reviewer.
This data extraction job can take a few hours to complete (possibly up to a day), which is largely dependent on the size of the project. The process is automated and no action is needed during this process. Once data extraction is complete, you will start getting suggestions in merge requests.
This data extraction job can take a few hours to complete (possibly up to a day), which is largely dependent on the size of the project. The process is automated and no action is needed during this process. Once data extraction is complete, you will start getting suggestions in merge requests.
### Generating suggestions
Once Suggested Reviewers is enabled and the data extraction is complete, new merge requests or new commits to existing merge requests will automatically trigger a Suggested Reviewers ML model inference and generate up to 5 suggested reviewers. These suggestions are contextual to the changes in the merge request. Additional commits to merge requests may change the reviewer suggestions which will automatically update in the reviewer dropdown.
Once Suggested Reviewers is enabled and the data extraction is complete, new merge requests or new commits to existing merge requests will automatically trigger a Suggested Reviewers ML model inference and generate up to 5 suggested reviewers. These suggestions are contextual to the changes in the merge request. Additional commits to merge requests may change the reviewer suggestions which will automatically update in the reviewer dropdown.
## Progressive enhancement
This feature is designed as a progressive enhancement to the existing GitLab Reviewers functionality. The GitLab Reviewer UI will only offer suggestions if the ML engine is able to provide a recommendation. In the event of an issue or model inference failure, the feature will gracefully degrade. At no point with the usage of Suggested Reviewers prevent a user from being able to manually set a reviewer.
This feature is designed as a progressive enhancement to the existing GitLab Reviewers functionality. The GitLab Reviewer UI will only offer suggestions if the ML engine is able to provide a recommendation. In the event of an issue or model inference failure, the feature will gracefully degrade. At no point with the usage of Suggested Reviewers prevent a user from being able to manually set a reviewer.
## Model Accuracy
Organizations use many different processes for code review. Some focus on senior engineers reviewing junior engineer's code, others have hierarchical organizational structure based reviews. Suggested Reviewers is focused on contextual reviewers based on historical merge request activity by users. While we will continue evolving the underlying ML model to better serve various code review use cases and processes Suggested Reviewers does not replace the usage of other code review features like Code Owners and [Approval Rules](../approvals/rules.md). Reviewer selection is highly subjective therefore, we do not expect Suggested Reviewers to provide perfect suggestions everytime.
Organizations use many different processes for code review. Some focus on senior engineers reviewing junior engineer's code, others have hierarchical organizational structure based reviews. Suggested Reviewers is focused on contextual reviewers based on historical merge request activity by users. While we will continue evolving the underlying ML model to better serve various code review use cases and processes Suggested Reviewers does not replace the usage of other code review features like Code Owners and [Approval Rules](../approvals/rules.md). Reviewer selection is highly subjective therefore, we do not expect Suggested Reviewers to provide perfect suggestions everytime.
Through analysis of beta customer usage, we find that the Suggested Reviewers ML model provides suggestions that are adopted in 60% of cases. We will be introducing a feedback mechanism into the Suggested Reviewers feature in the future to allow users to flag bad reviewer suggestions to help improve the model. Additionally we will be offering an opt-in feature in the future which will allow the model to use your project's data for training the underlying model.
## Off by default
Suggested Reviewers is off by default and requires a Project Owner or Admin to enable the feature.
Suggested Reviewers is off by default and requires a Project Owner or Admin to enable the feature.
## Data privacy
Suggested Reviewers operates completely within the GitLab.com infrastructure providing the same level of [privacy](https://about.gitlab.com/privacy/) and [security](https://about.gitlab.com/security/) of any other feature of GitLab.com.
Suggested Reviewers operates completely within the GitLab.com infrastructure providing the same level of [privacy](https://about.gitlab.com/privacy/) and [security](https://about.gitlab.com/security/) of any other feature of GitLab.com.
No new additional data is collected to enable this feature, simply GitLab is inferencing your merge request against a trained machine learning model. The content of your source code is not used as training data. Your data also never leaves GitLab.com, all training and inference is done within GitLab.com infrastructure.

View File

@ -115,7 +115,7 @@ To change the assignee on a task:
1. In the issue description, in the **Tasks** section, select the title of the task you want to edit.
The task window opens.
1. Next to **Assignees**, select **Add assignees**.
1. From the dropdown list, select the user(s) to add as an assignee.
1. From the dropdown list, select the users to add as an assignee.
1. Select any area outside the dropdown list.
## Set a start and due date

View File

@ -13,7 +13,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
## Namespace storage limit
Namespaces on GitLab SaaS have a storage limit. For more information, see our [pricing page](https://about.gitlab.com/pricing/).
This limit is not visible on the Usage quotas page, but will be prior to [enforcement](#namespace-storage-limit-enforcement-schedule). Self-managed deployments are not affected.
This limit is not visible on the Usage quotas page, but will be prior to the limit being [applied](#namespace-storage-limit-application-schedule). Self-managed deployments are not affected.
Storage types that add to the total namespace storage are:
@ -38,16 +38,14 @@ To prevent exceeding the namespace storage quota, you can:
- [Start a trial](https://about.gitlab.com/free-trial/) or [upgrade to GitLab Premium or Ultimate](https://about.gitlab.com/pricing) which include higher limits and features that enable growing teams to ship faster without sacrificing on quality.
- [Talk to an expert](https://page.gitlab.com/usage_limits_help.html) to learn more about your options and ask questions.
### Namespace storage limit enforcement schedule
### Namespace storage limit application schedule
Storage limits for GitLab SaaS Free tier namespaces will not be enforced prior to 2022-10-19. Storage limits for GitLab SaaS Paid tier namespaces will not be enforced for prior to 2023-02-15. Enforcement will not occur until all storage types are accurately measured, including deduplication of forks for [Git](https://gitlab.com/gitlab-org/gitlab/-/issues/371671) and [LFS](https://gitlab.com/gitlab-org/gitlab/-/issues/370242).
Information on when namespace-level storage limits will be applied is available on these FAQ pages for the [Free](https://about.gitlab.com/pricing/faq-efficient-free-tier/#storage-limits-on-gitlab-saas-free-tier) and [Paid](https://about.gitlab.com/pricing/faq-paid-storage-transfer/) tier.
Impacted users are notified by email and through in-app notifications at least 60 days prior to enforcement.
### Project storage limit
## Project storage limit
Projects on GitLab SaaS have a 10GB storage limit on their Git repository and LFS storage.
After namespace-level storage limits are enforced, the project limit is removed. A namespace has either a namespace-level storage limit or a project-level storage limit, but not both.
After namespace-level storage limits are applied, the project limit will be removed. A namespace has either a namespace-level storage limit or a project-level storage limit, but not both.
When a project's repository and LFS reaches the quota, the project is locked.
You cannot push changes to a locked project. To monitor the size of each
@ -102,7 +100,7 @@ Depending on your role, you can also use the following methods to manage or redu
## Excess storage usage
Excess storage usage is the amount that a project's repository and LFS exceeds the free storage quota. If no
Excess storage usage is the amount that a project's repository and LFS exceeds the [project storage limit](#project-storage-limit). If no
purchased storage is available the project is locked. You cannot push changes to a locked project.
To unlock a project you must [purchase more storage](../subscriptions/gitlab_com/index.md#purchase-more-storage-and-transfer)
for the namespace. When the purchase is completed, locked projects are automatically unlocked. The

View File

@ -18,9 +18,11 @@ module Gitlab
# `Sidekiq.redis` is a namespaced redis connection. This means keys are actually being stored under
# "resque:gitlab:resque:gitlab:duplicate:". For backwards compatibility, we make the secondary store
# namespaced in the same way, but omit it from the primary so keys have proper format there.
# rubocop:disable Cop/RedisQueueUsage
secondary_store = ::Redis::Namespace.new(
Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE, redis: ::Redis.new(Gitlab::Redis::Queues.params)
)
# rubocop:enable Cop/RedisQueueUsage
MultiStore.new(primary_store, secondary_store, name.demodulize)
end

View File

@ -14,7 +14,7 @@ module Gitlab
def redis
primary_store = ::Redis.new(Gitlab::Redis::SharedState.params)
secondary_store = ::Redis.new(Gitlab::Redis::Queues.params)
secondary_store = ::Redis.new(Gitlab::Redis::Queues.params) # rubocop:disable Cop/RedisQueueUsage
MultiStore.new(primary_store, secondary_store, name.demodulize)
end

View File

@ -282,7 +282,7 @@ module Gitlab
Gitlab::Redis::DuplicateJobs.with { |redis| yield redis }
else
# Keep the old behavior intact if neither feature flag is turned on
Sidekiq.redis { |redis| yield redis }
Sidekiq.redis { |redis| yield redis } # rubocop:disable Cop/SidekiqRedisCall
end
end
end

View File

@ -126,7 +126,7 @@ module Gitlab
Gitlab::Redis::SidekiqStatus.with { |redis| yield redis }
else
# Keep the old behavior intact if neither feature flag is turned on
Sidekiq.redis { |redis| yield redis }
Sidekiq.redis { |redis| yield redis } # rubocop:disable Cop/SidekiqRedisCall
end
end
private_class_method :with_redis

View File

@ -2384,12 +2384,6 @@ msgstr ""
msgid "Add trigger"
msgstr ""
msgid "Add user(s) to the group:"
msgstr ""
msgid "Add users to group"
msgstr ""
msgid "Add variable"
msgstr ""
@ -17355,6 +17349,9 @@ msgstr ""
msgid "Geo|Errors:"
msgstr ""
msgid "Geo|External URL"
msgstr ""
msgid "Geo|Failed"
msgstr ""
@ -32889,9 +32886,6 @@ msgstr ""
msgid "Read more about GitLab at %{link_to_promo}."
msgstr ""
msgid "Read more about project permissions %{help_link_open}here%{help_link_close}"
msgstr ""
msgid "Read more about related epics"
msgstr ""
@ -34779,6 +34773,9 @@ msgstr ""
msgid "Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console."
msgstr ""
msgid "Runners|Show only inherited"
msgstr ""
msgid "Runners|Show runner installation and registration instructions"
msgstr ""
@ -43745,9 +43742,6 @@ msgstr ""
msgid "Users to exclude from the rate limit"
msgstr ""
msgid "Users were successfully added."
msgstr ""
msgid "Users with a Guest role or those who don't belong to a Project or Group will not use a seat from your license."
msgstr ""

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module RuboCop
module Cop
# This class complements Rubocop::Cop::SidekiqRedisCall by disallowing the use of
# Gitlab::Redis::Queues with the exception of initialising Sidekiq and monitoring.
class RedisQueueUsage < RuboCop::Cop::Base
MSG = 'Gitlab::Redis::Queues should only be used by Sidekiq initializers. '\
'Assignments or using its params to initialise another connection is not allowed.'
def_node_matcher :calling_redis_queue_module_methods?, <<~PATTERN
(send (const (const (const nil? :Gitlab) :Redis) :Queues) ...)
PATTERN
def_node_matcher :using_redis_queue_module_as_parameter?, <<~PATTERN
(send ... (const (const (const nil? :Gitlab) :Redis) :Queues))
PATTERN
def_node_matcher :redis_queue_assignment?, <<~PATTERN
({lvasgn | ivasgn | cvasgn | gvasgn | casgn | masgn | op_asgn | or_asgn | and_asgn } ...
`(const (const (const nil? :Gitlab) :Redis) :Queues))
PATTERN
def on_send(node)
return unless using_redis_queue_module_as_parameter?(node) || calling_redis_queue_module_methods?(node)
add_offense(node, message: MSG)
end
# offenses caught in assignment may overlap with on_send
%i[on_lvasgn on_ivasgn on_cvasgn on_gvasgn on_casgn on_masgn on_op_asgn on_or_asgn on_and_asgn].each do |name|
define_method(name) do |node|
add_offense(node, message: MSG) if redis_queue_assignment?(node)
end
end
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module RuboCop
module Cop
# Cop that prevents manually setting a queue in Sidekiq workers.
class SidekiqRedisCall < RuboCop::Cop::Base
MSG = 'Refrain from directly using Sidekiq.redis unless for migration. For admin operations, use Sidekiq APIs.'
def_node_matcher :using_sidekiq_redis?, <<~PATTERN
(send (const nil? :Sidekiq) :redis)
PATTERN
def on_send(node)
add_offense(node, message: MSG) if using_sidekiq_redis?(node)
end
end
end
end

View File

@ -44,64 +44,4 @@ RSpec.describe Admin::GroupsController do
end.to change { Namespace::AdminNote.count }.by(1)
end
end
describe 'PUT #members_update' do
let_it_be(:group_user) { create(:user) }
it 'adds user to members', :aggregate_failures, :snowplow do
put :members_update, params: {
id: group,
user_id: group_user.id,
access_level: Gitlab::Access::GUEST
}
expect(controller).to set_flash.to 'Users were successfully added.'
expect(response).to redirect_to(admin_group_path(group))
expect(group.users).to include group_user
expect_snowplow_event(
category: 'Members::CreateService',
action: 'create_member',
label: 'admin-group-page',
property: 'existing_user',
user: admin
)
end
it 'can add unlimited members', :aggregate_failures do
put :members_update, params: {
id: group,
user_id: 1.upto(1000).to_a.join(','),
access_level: Gitlab::Access::GUEST
}
expect(controller).to set_flash.to 'Users were successfully added.'
expect(response).to redirect_to(admin_group_path(group))
end
it 'adds no user to members', :aggregate_failures do
put :members_update, params: {
id: group,
user_id: '',
access_level: Gitlab::Access::GUEST
}
expect(controller).to set_flash.to 'No users specified.'
expect(response).to redirect_to(admin_group_path(group))
expect(group.users).not_to include group_user
end
it 'updates the project_creation_level successfully' do
expect do
post :update, params: { id: group.to_param, group: { project_creation_level: ::Gitlab::Access::NO_ONE_PROJECT_ACCESS } }
end.to change { group.reload.project_creation_level }.to(::Gitlab::Access::NO_ONE_PROJECT_ACCESS)
end
it 'updates the subgroup_creation_level successfully' do
expect do
post :update,
params: { id: group.to_param,
group: { subgroup_creation_level: ::Gitlab::Access::OWNER_SUBGROUP_ACCESS } }
end.to change { group.reload.subgroup_creation_level }.to(::Gitlab::Access::OWNER_SUBGROUP_ACCESS)
end
end
end

View File

@ -207,31 +207,6 @@ RSpec.describe 'Admin Groups' do
end
describe 'add user into a group', :js do
shared_examples 'adds user into a group' do
it do
visit admin_group_path(group)
select2(user_selector, from: '#user_id', multiple: true)
page.within '#new_project_member' do
select2(Gitlab::Access::REPORTER, from: '#access_level')
end
click_button "Add users to group"
page.within ".group-users-list" do
expect(page).to have_content(user.name)
expect(page).to have_content('Reporter')
end
end
end
it_behaves_like 'adds user into a group' do
let(:user_selector) { user.id }
end
it_behaves_like 'adds user into a group' do
let(:user_selector) { user.email }
end
context 'when membership is set to expire' do
it 'renders relative time' do
expire_time = Time.current + 2.days

View File

@ -7,7 +7,7 @@ RSpec.describe 'Admin updates settings' do
include TermsHelper
include UsageDataHelpers
let(:admin) { create(:admin) }
let_it_be(:admin) { create(:admin) }
let(:dot_com?) { false }
context 'application setting :admin_mode is enabled', :request_store do

View File

@ -1,98 +1,100 @@
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlSprintf } from '@gitlab/ui';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import { stubComponent } from 'helpers/stub_component';
import axios from '~/lib/utils/axios_utils';
import promoteLabelModal from '~/labels/components/promote_label_modal.vue';
import PromoteLabelModal from '~/labels/components/promote_label_modal.vue';
import eventHub from '~/labels/event_hub';
describe('Promote label modal', () => {
let vm;
const Component = Vue.extend(promoteLabelModal);
let wrapper;
let axiosMock;
const labelMockData = {
labelTitle: 'Documentation',
labelColor: '#5cb85c',
labelTextColor: '#ffffff',
labelColor: 'rgb(92, 184, 92)',
labelTextColor: 'rgb(255, 255, 255)',
url: `${TEST_HOST}/dummy/promote/labels`,
groupName: 'group',
};
const createComponent = () => {
wrapper = shallowMount(PromoteLabelModal, {
propsData: labelMockData,
stubs: {
GlSprintf,
GlModal: stubComponent(GlModal, {
template: `<div><slot name="modal-title"></slot><slot></slot></div>`,
}),
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
createComponent();
});
afterEach(() => {
axiosMock.reset();
wrapper.destroy();
});
describe('Modal title and description', () => {
beforeEach(() => {
vm = mountComponent(Component, labelMockData);
});
afterEach(() => {
vm.$destroy();
});
it('contains the proper description', () => {
expect(vm.text).toContain(
expect(wrapper.text()).toContain(
`Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`,
);
});
it('contains a label span with the color', () => {
expect(vm.labelColor).not.toBe(null);
expect(vm.labelColor).toBe(labelMockData.labelColor);
expect(vm.labelTitle).toBe(labelMockData.labelTitle);
const label = wrapper.find('.modal-title-with-label .label');
expect(label.element.style.backgroundColor).toBe(labelMockData.labelColor);
expect(label.element.style.color).toBe(labelMockData.labelTextColor);
expect(label.text()).toBe(labelMockData.labelTitle);
});
});
describe('When requesting a label promotion', () => {
beforeEach(() => {
vm = mountComponent(Component, {
...labelMockData,
});
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
afterEach(() => {
vm.$destroy();
});
it('redirects when a label is promoted', () => {
it('redirects when a label is promoted', async () => {
const responseURL = `${TEST_HOST}/dummy/endpoint`;
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(labelMockData.url);
expect(eventHub.$emit).toHaveBeenCalledWith(
'promoteLabelModal.requestStarted',
labelMockData.url,
);
return Promise.resolve({
request: {
responseURL,
},
});
});
axiosMock.onPost(labelMockData.url).reply(200, { url: responseURL });
return vm.onSubmit().then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
labelUrl: labelMockData.url,
successful: true,
});
wrapper.findComponent(GlModal).vm.$emit('primary');
expect(eventHub.$emit).toHaveBeenCalledWith(
'promoteLabelModal.requestStarted',
labelMockData.url,
);
await axios.waitForAll();
expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
labelUrl: labelMockData.url,
successful: true,
});
});
it('displays an error if promoting a label failed', () => {
it('displays an error if promoting a label failed', async () => {
const dummyError = new Error('promoting label failed');
dummyError.response = { status: 500 };
axiosMock.onPost(labelMockData.url).reply(500, { error: dummyError });
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(labelMockData.url);
expect(eventHub.$emit).toHaveBeenCalledWith(
'promoteLabelModal.requestStarted',
labelMockData.url,
);
wrapper.findComponent(GlModal).vm.$emit('primary');
return Promise.reject(dummyError);
});
await axios.waitForAll();
return vm.onSubmit().catch((error) => {
expect(error).toBe(dummyError);
expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
labelUrl: labelMockData.url,
successful: false,
});
expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
labelUrl: labelMockData.url,
successful: false,
});
});
});

View File

@ -1,9 +1,10 @@
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
import StopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
@ -14,20 +15,23 @@ describe('stop_jobs_modal.vue', () => {
const props = {
url: `${TEST_HOST}/stop_jobs_modal.vue/stopAll`,
};
let vm;
afterEach(() => {
vm.$destroy();
});
let wrapper;
beforeEach(() => {
const Component = Vue.extend(stopJobsModal);
vm = mountComponent(Component, props);
wrapper = mount(StopJobsModal, { propsData: props });
});
describe('onSubmit', () => {
afterEach(() => {
wrapper.destroy();
});
describe('on submit', () => {
it('stops jobs and redirects to overview page', async () => {
const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`;
// TODO: We can't use axios-mock-adapter because our current version
// does not support responseURL
//
// see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(props.url);
return Promise.resolve({
@ -37,18 +41,28 @@ describe('stop_jobs_modal.vue', () => {
});
});
await vm.onSubmit();
wrapper.findComponent(GlModal).vm.$emit('primary');
await nextTick();
expect(redirectTo).toHaveBeenCalledWith(responseURL);
});
it('displays error if stopping jobs failed', async () => {
Vue.config.errorHandler = () => {}; // silencing thrown error
const dummyError = new Error('stopping jobs failed');
// TODO: We can't use axios-mock-adapter because our current version
// does not support responseURL
//
// see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(props.url);
return Promise.reject(dummyError);
});
await expect(vm.onSubmit()).rejects.toEqual(dummyError);
wrapper.findComponent(GlModal).vm.$emit('primary');
await nextTick();
expect(redirectTo).not.toHaveBeenCalled();
});
});

View File

@ -1,5 +1,5 @@
import Vue, { nextTick } from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import PageComponent from '~/pdf/page/index.vue';
jest.mock('pdfjs-dist/webpack', () => {
@ -7,11 +7,10 @@ jest.mock('pdfjs-dist/webpack', () => {
});
describe('Page component', () => {
const Component = Vue.extend(PageComponent);
let vm;
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('renders the page when mounting', async () => {
@ -20,16 +19,18 @@ describe('Page component', () => {
getViewport: jest.fn().mockReturnValue({}),
};
vm = mountComponent(Component, {
page: testPage,
number: 1,
wrapper = mount(PageComponent, {
propsData: {
page: testPage,
number: 1,
},
});
expect(vm.rendering).toBe(true);
await nextTick();
expect(testPage.render).toHaveBeenCalledWith(vm.renderContext);
expect(vm.rendering).toBe(false);
expect(testPage.render).toHaveBeenCalledWith({
canvasContext: wrapper.find('canvas').element.getContext('2d'),
viewport: testPage.getViewport(),
});
});
});

View File

@ -45,6 +45,7 @@ import {
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
STATUS_ONLINE,
DEFAULT_MEMBERSHIP,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
@ -221,6 +222,7 @@ describe('AdminRunnersApp', () => {
expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: undefined,
type: undefined,
membership: DEFAULT_MEMBERSHIP,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
@ -290,6 +292,7 @@ describe('AdminRunnersApp', () => {
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
filters: [
{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
{ type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } },
@ -303,6 +306,7 @@ describe('AdminRunnersApp', () => {
expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: STATUS_ONLINE,
type: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
paused: true,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
@ -312,6 +316,7 @@ describe('AdminRunnersApp', () => {
it('fetches count results for requested status', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledWith({
type: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
status: STATUS_ONLINE,
paused: true,
});
@ -324,6 +329,7 @@ describe('AdminRunnersApp', () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
@ -341,6 +347,7 @@ describe('AdminRunnersApp', () => {
it('requests the runners with filters', () => {
expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: STATUS_ONLINE,
membership: DEFAULT_MEMBERSHIP,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
@ -349,6 +356,7 @@ describe('AdminRunnersApp', () => {
it('fetches count results for requested status', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledWith({
status: STATUS_ONLINE,
membership: DEFAULT_MEMBERSHIP,
});
});
});
@ -459,6 +467,7 @@ describe('AdminRunnersApp', () => {
beforeEach(async () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
@ -506,6 +515,7 @@ describe('AdminRunnersApp', () => {
await findRunnerPaginationNext().trigger('click');
expect(mockRunnersHandler).toHaveBeenLastCalledWith({
membership: DEFAULT_MEMBERSHIP,
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: pageInfo.endCursor,

View File

@ -4,10 +4,26 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config';
import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } from '~/runner/constants';
import {
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
STATUS_ONLINE,
INSTANCE_TYPE,
DEFAULT_MEMBERSHIP,
DEFAULT_SORT,
CONTACTED_DESC,
} from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
const mockSearch = {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { page: 1 },
sort: DEFAULT_SORT,
};
describe('RunnerList', () => {
let wrapper;
@ -15,8 +31,7 @@ describe('RunnerList', () => {
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockOtherSort = CONTACTED_DESC;
const mockFilters = [
{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
@ -32,11 +47,7 @@ describe('RunnerList', () => {
propsData: {
namespace: 'runners',
tokens: [],
value: {
runnerType: null,
filters: [],
sort: mockDefaultSort,
},
value: mockSearch,
...props,
},
stubs: {
@ -115,6 +126,7 @@ describe('RunnerList', () => {
props: {
value: {
runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
sort: mockOtherSort,
filters: mockFilters,
},
@ -141,6 +153,7 @@ describe('RunnerList', () => {
expectToHaveLastEmittedInput({
runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
filters: mockFilters,
sort: mockOtherSort,
pagination: {},
@ -154,8 +167,9 @@ describe('RunnerList', () => {
expectToHaveLastEmittedInput({
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: mockFilters,
sort: mockDefaultSort,
sort: DEFAULT_SORT,
pagination: {},
});
});
@ -165,6 +179,7 @@ describe('RunnerList', () => {
expectToHaveLastEmittedInput({
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
sort: mockOtherSort,
pagination: {},

View File

@ -0,0 +1,57 @@
import { GlToggle } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue';
import {
I18N_SHOW_ONLY_INHERITED,
MEMBERSHIP_DESCENDANTS,
MEMBERSHIP_ALL_AVAILABLE,
} from '~/runner/constants';
describe('RunnerMembershipToggle', () => {
let wrapper;
const findToggle = () => wrapper.findComponent(GlToggle);
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(RunnerMembershipToggle, {
propsData: props,
});
};
afterEach(() => {
wrapper.destroy();
});
it('Displays text', () => {
createComponent({ mountFn: mount });
expect(wrapper.text()).toBe(I18N_SHOW_ONLY_INHERITED);
});
it.each`
membershipValue | toggleValue
${MEMBERSHIP_DESCENDANTS} | ${true}
${MEMBERSHIP_ALL_AVAILABLE} | ${false}
`(
'Displays a membership of $membershipValue as enabled=$toggleValue',
({ membershipValue, toggleValue }) => {
createComponent({ props: { value: membershipValue } });
expect(findToggle().props('value')).toBe(toggleValue);
},
);
it.each`
changeEvt | membershipValue
${true} | ${MEMBERSHIP_DESCENDANTS}
${false} | ${MEMBERSHIP_ALL_AVAILABLE}
`(
'Emits $changeEvt when value is changed to $membershipValue',
({ changeEvt, membershipValue }) => {
createComponent();
findToggle().vm.$emit('change', changeEvt);
expect(wrapper.emitted('input')).toStrictEqual([[membershipValue]]);
},
);
});

View File

@ -2,9 +2,21 @@ import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerCount from '~/runner/components/stat/runner_count.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
DEFAULT_MEMBERSHIP,
DEFAULT_SORT,
} from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
const mockSearch = {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { page: 1 },
sort: DEFAULT_SORT,
};
const mockCount = (type, multiplier = 1) => {
let count;

View File

@ -24,6 +24,7 @@ import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue';
import {
CREATED_ASC,
@ -39,6 +40,8 @@ import {
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
MEMBERSHIP_ALL_AVAILABLE,
MEMBERSHIP_DESCENDANTS,
RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants';
@ -89,8 +92,14 @@ describe('GroupRunnersApp', () => {
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next'));
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findRunnerMembershipToggle = () => wrapper.findComponent(RunnerMembershipToggle);
const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
const createComponent = ({
props = {},
provide = {},
mountFn = shallowMountExtended,
...options
} = {}) => {
const handlers = [
[groupRunnersQuery, mockGroupRunnersHandler],
[groupRunnersCountQuery, mockGroupRunnersCountHandler],
@ -109,6 +118,7 @@ describe('GroupRunnersApp', () => {
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
...provide,
},
...options,
});
@ -147,19 +157,75 @@ describe('GroupRunnersApp', () => {
expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
});
describe('show all available runners toggle', () => {
describe('when runners_finder_all_available is enabled', () => {
it('shows the membership toggle', () => {
createComponent({
provide: {
glFeatures: { runnersFinderAllAvailable: true },
},
});
expect(findRunnerMembershipToggle().exists()).toBe(true);
});
it('sets the membership toggle', () => {
setWindowLocation(`?membership[]=${MEMBERSHIP_ALL_AVAILABLE}`);
createComponent({
provide: {
glFeatures: { runnersFinderAllAvailable: true },
},
});
expect(findRunnerMembershipToggle().props('value')).toBe(MEMBERSHIP_ALL_AVAILABLE);
});
it('requests filter', async () => {
createComponent({
provide: {
glFeatures: { runnersFinderAllAvailable: true },
},
});
findRunnerMembershipToggle().vm.$emit('input', MEMBERSHIP_ALL_AVAILABLE);
await waitForPromises();
expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith(
expect.objectContaining({
membership: MEMBERSHIP_ALL_AVAILABLE,
}),
);
});
});
describe('when runners_finder_all_available is disabled', () => {
beforeEach(() => {
createComponent();
});
it('does not show the membership toggle', () => {
expect(findRunnerMembershipToggle().exists()).toBe(false);
});
});
});
it('shows total runner counts', async () => {
await createComponent({ mountFn: mountExtended });
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
status: STATUS_ONLINE,
membership: MEMBERSHIP_DESCENDANTS,
groupFullPath: mockGroupFullPath,
});
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
status: STATUS_OFFLINE,
membership: MEMBERSHIP_DESCENDANTS,
groupFullPath: mockGroupFullPath,
});
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
status: STATUS_STALE,
membership: MEMBERSHIP_DESCENDANTS,
groupFullPath: mockGroupFullPath,
});
@ -183,6 +249,7 @@ describe('GroupRunnersApp', () => {
groupFullPath: mockGroupFullPath,
status: undefined,
type: undefined,
membership: MEMBERSHIP_DESCENDANTS,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
@ -266,6 +333,7 @@ describe('GroupRunnersApp', () => {
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
runnerType: INSTANCE_TYPE,
membership: MEMBERSHIP_DESCENDANTS,
filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }],
sort: 'CREATED_DESC',
pagination: {},
@ -277,6 +345,7 @@ describe('GroupRunnersApp', () => {
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
type: INSTANCE_TYPE,
membership: MEMBERSHIP_DESCENDANTS,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
@ -286,6 +355,7 @@ describe('GroupRunnersApp', () => {
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
groupFullPath: mockGroupFullPath,
type: INSTANCE_TYPE,
membership: MEMBERSHIP_DESCENDANTS,
status: STATUS_ONLINE,
});
});
@ -297,6 +367,7 @@ describe('GroupRunnersApp', () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
membership: MEMBERSHIP_DESCENDANTS,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
@ -315,6 +386,7 @@ describe('GroupRunnersApp', () => {
expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
membership: MEMBERSHIP_DESCENDANTS,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
@ -324,6 +396,7 @@ describe('GroupRunnersApp', () => {
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
membership: MEMBERSHIP_DESCENDANTS,
});
});
});
@ -395,6 +468,7 @@ describe('GroupRunnersApp', () => {
expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
membership: MEMBERSHIP_DESCENDANTS,
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: pageInfo.endCursor,

View File

@ -17,7 +17,7 @@ import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.qu
import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json';
import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json';
import { RUNNER_PAGE_SIZE } from '~/runner/constants';
import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/runner/constants';
const emptyPageInfo = {
__typename: 'PageInfo',
@ -34,8 +34,18 @@ export const mockSearchExamples = [
{
name: 'a default query',
urlQuery: '',
search: { runnerType: null, filters: [], pagination: {}, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
isDefault: true,
},
{
@ -43,17 +53,24 @@ export const mockSearchExamples = [
urlQuery: '?status[]=ACTIVE',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
status: 'ACTIVE',
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'a single term text search',
urlQuery: '?search=something',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [
{
type: 'filtered-search-term',
@ -63,13 +80,19 @@ export const mockSearchExamples = [
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
search: 'something',
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'a two terms text search',
urlQuery: '?search=something+else',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [
{
type: 'filtered-search-term',
@ -83,24 +106,36 @@ export const mockSearchExamples = [
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
search: 'something else',
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: {
runnerType: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
type: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'multiple runner status',
urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'status', value: { data: 'PAUSED', operator: '=' } },
@ -108,13 +143,19 @@ export const mockSearchExamples = [
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
status: 'ACTIVE',
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'multiple status, a single instance type and a non default sort',
urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
search: {
runnerType: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
pagination: {},
sort: 'CREATED_ASC',
@ -122,6 +163,7 @@ export const mockSearchExamples = [
graphqlVariables: {
status: 'ACTIVE',
type: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_ASC',
first: RUNNER_PAGE_SIZE,
},
@ -131,11 +173,13 @@ export const mockSearchExamples = [
urlQuery: '?tag[]=tag-1',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1'],
first: 20,
sort: 'CREATED_DESC',
@ -146,6 +190,7 @@ export const mockSearchExamples = [
urlQuery: '?tag[]=tag-1&tag[]=tag-2',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'tag', value: { data: 'tag-2', operator: '=' } },
@ -154,6 +199,7 @@ export const mockSearchExamples = [
sort: 'CREATED_DESC',
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1', 'tag-2'],
first: 20,
sort: 'CREATED_DESC',
@ -164,22 +210,34 @@ export const mockSearchExamples = [
urlQuery: '?after=AFTER_CURSOR',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { after: 'AFTER_CURSOR' },
sort: 'CREATED_DESC',
},
graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
after: 'AFTER_CURSOR',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'the previous page',
urlQuery: '?before=BEFORE_CURSOR',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { before: 'BEFORE_CURSOR' },
sort: 'CREATED_DESC',
},
graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE },
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
before: 'BEFORE_CURSOR',
last: RUNNER_PAGE_SIZE,
},
},
{
name: 'the next page filtered by a status, an instance type, tags and a non default sort',
@ -187,6 +245,7 @@ export const mockSearchExamples = [
'?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR',
search: {
runnerType: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
@ -198,6 +257,7 @@ export const mockSearchExamples = [
graphqlVariables: {
status: 'ACTIVE',
type: 'INSTANCE_TYPE',
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1', 'tag-2'],
sort: 'CREATED_ASC',
after: 'AFTER_CURSOR',
@ -209,22 +269,34 @@ export const mockSearchExamples = [
urlQuery: '?paused[]=true',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
paused: true,
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'active runners',
urlQuery: '?paused[]=false',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }],
pagination: {},
sort: 'CREATED_DESC',
},
graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
graphqlVariables: {
paused: false,
membership: DEFAULT_MEMBERSHIP,
sort: 'CREATED_DESC',
first: RUNNER_PAGE_SIZE,
},
},
];

View File

@ -1,180 +1,172 @@
import { getByRole } from '@testing-library/dom';
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue';
import MergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
describe('MRWidgetMerged', () => {
let vm;
let wrapper;
const targetBranch = 'foo';
const mr = {
isRemovingSourceBranch: false,
cherryPickInForkPath: false,
canCherryPickInCurrentMR: true,
revertInForkPath: false,
canRevertInCurrentMR: true,
canRemoveSourceBranch: true,
sourceBranchRemoved: true,
metrics: {
mergedBy: {
name: 'Administrator',
username: 'root',
webUrl: 'http://localhost:3000/root',
avatarUrl:
'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
mergedAt: 'Jan 24, 2018 1:02pm UTC',
readableMergedAt: '',
closedBy: {},
closedAt: 'Jan 24, 2018 1:02pm UTC',
readableClosedAt: '',
},
updatedAt: 'mergedUpdatedAt',
shortMergeCommitSha: '958c0475',
mergeCommitSha: '958c047516e182dfc52317f721f696e8a1ee85ed',
mergeCommitPath:
'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d',
sourceBranch: 'bar',
targetBranch,
};
const service = {
removeSourceBranch: () => nextTick(),
};
const createComponent = (customMrFields = {}) => {
wrapper = mount(MergedComponent, {
propsData: {
mr: {
...mr,
...customMrFields,
},
service,
},
});
};
beforeEach(() => {
jest.spyOn(document, 'dispatchEvent');
const Component = Vue.extend(mergedComponent);
const mr = {
isRemovingSourceBranch: false,
cherryPickInForkPath: false,
canCherryPickInCurrentMR: true,
revertInForkPath: false,
canRevertInCurrentMR: true,
canRemoveSourceBranch: true,
sourceBranchRemoved: true,
metrics: {
mergedBy: {
name: 'Administrator',
username: 'root',
webUrl: 'http://localhost:3000/root',
avatarUrl:
'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
mergedAt: 'Jan 24, 2018 1:02pm UTC',
readableMergedAt: '',
closedBy: {},
closedAt: 'Jan 24, 2018 1:02pm UTC',
readableClosedAt: '',
},
updatedAt: 'mergedUpdatedAt',
shortMergeCommitSha: '958c0475',
mergeCommitSha: '958c047516e182dfc52317f721f696e8a1ee85ed',
mergeCommitPath:
'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d',
sourceBranch: 'bar',
targetBranch,
};
const service = {
removeSourceBranch() {},
};
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
vm = mountComponent(Component, { mr, service });
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('computed', () => {
describe('shouldShowRemoveSourceBranch', () => {
it('returns true when sourceBranchRemoved is false', () => {
vm.mr.sourceBranchRemoved = false;
const findButtonByText = (text) =>
wrapper.findAll('button').wrappers.find((w) => w.text() === text);
const findRemoveSourceBranchButton = () => findButtonByText('Delete source branch');
expect(vm.shouldShowRemoveSourceBranch).toEqual(true);
});
describe('remove source branch button', () => {
it('is displayed when sourceBranchRemoved is false', () => {
createComponent({ sourceBranchRemoved: false });
it('returns false when sourceBranchRemoved is true', () => {
vm.mr.sourceBranchRemoved = true;
expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
});
it('returns false when canRemoveSourceBranch is false', () => {
vm.mr.sourceBranchRemoved = false;
vm.mr.canRemoveSourceBranch = false;
expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
});
it('returns false when is making request', () => {
vm.mr.canRemoveSourceBranch = true;
vm.isMakingRequest = true;
expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
});
it('returns true when all are true', () => {
vm.mr.isRemovingSourceBranch = true;
vm.mr.canRemoveSourceBranch = true;
vm.isMakingRequest = true;
expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
});
expect(findRemoveSourceBranchButton().exists()).toBe(true);
});
describe('shouldShowSourceBranchRemoving', () => {
it('should correct value when fields changed', () => {
vm.mr.sourceBranchRemoved = false;
it('is not displayed when sourceBranchRemoved is true', () => {
createComponent({ sourceBranchRemoved: true });
expect(vm.shouldShowSourceBranchRemoving).toEqual(false);
expect(findRemoveSourceBranchButton()).toBe(undefined);
});
vm.mr.sourceBranchRemoved = true;
it('is not displayed when canRemoveSourceBranch is true', () => {
createComponent({ sourceBranchRemoved: false, canRemoveSourceBranch: false });
expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
expect(findRemoveSourceBranchButton()).toBe(undefined);
});
vm.mr.sourceBranchRemoved = false;
vm.isMakingRequest = true;
it('is not displayed when is making request', async () => {
createComponent({ sourceBranchRemoved: false, canRemoveSourceBranch: true });
expect(vm.shouldShowSourceBranchRemoving).toEqual(true);
await findRemoveSourceBranchButton().trigger('click');
vm.isMakingRequest = false;
vm.mr.isRemovingSourceBranch = true;
expect(findRemoveSourceBranchButton()).toBe(undefined);
});
expect(vm.shouldShowSourceBranchRemoving).toEqual(true);
it('is not displayed when all are true', () => {
createComponent({
isRemovingSourceBranch: true,
sourceBranchRemoved: false,
canRemoveSourceBranch: true,
});
expect(findRemoveSourceBranchButton()).toBe(undefined);
});
});
describe('methods', () => {
describe('removeSourceBranch', () => {
it('should set flag and call service then request main component to update the widget', async () => {
jest.spyOn(vm.service, 'removeSourceBranch').mockReturnValue(
new Promise((resolve) => {
resolve({
data: {
message: 'Branch was deleted',
},
});
}),
);
vm.removeSourceBranch();
await waitForPromises();
const args = eventHub.$emit.mock.calls[0];
expect(vm.isMakingRequest).toEqual(true);
expect(args[0]).toEqual('MRWidgetUpdateRequested');
expect(args[1]).not.toThrow();
});
it('should set flag and call service then request main component to update the widget when branch is removed', async () => {
createComponent({ sourceBranchRemoved: false });
jest.spyOn(service, 'removeSourceBranch').mockResolvedValue({
data: {
message: 'Branch was deleted',
},
});
await findRemoveSourceBranchButton().trigger('click');
await waitForPromises();
const args = eventHub.$emit.mock.calls[0];
expect(args[0]).toEqual('MRWidgetUpdateRequested');
expect(args[1]).not.toThrow();
});
it('calls dispatchDocumentEvent to load in the modal component', () => {
createComponent();
expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('merged:UpdateActions'));
});
it('emits event to open the revert modal on revert button click', () => {
createComponent();
const eventHubSpy = jest.spyOn(modalEventHub, '$emit');
getByRole(vm.$el, 'button', { name: /Revert/i }).click();
getByRole(wrapper.element, 'button', { name: /Revert/i }).click();
expect(eventHubSpy).toHaveBeenCalledWith(OPEN_REVERT_MODAL);
});
it('emits event to open the cherry-pick modal on cherry-pick button click', () => {
createComponent();
const eventHubSpy = jest.spyOn(modalEventHub, '$emit');
getByRole(vm.$el, 'button', { name: /Cherry-pick/i }).click();
getByRole(wrapper.element, 'button', { name: /Cherry-pick/i }).click();
expect(eventHubSpy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL);
});
it('has merged by information', () => {
expect(vm.$el.textContent).toContain('Merged by');
expect(vm.$el.textContent).toContain('Administrator');
createComponent();
expect(wrapper.text()).toContain('Merged by');
expect(wrapper.text()).toContain('Administrator');
});
it('shows revert and cherry-pick buttons', () => {
expect(vm.$el.textContent).toContain('Revert');
expect(vm.$el.textContent).toContain('Cherry-pick');
createComponent();
expect(wrapper.text()).toContain('Revert');
expect(wrapper.text()).toContain('Cherry-pick');
});
it('should use mergedEvent mergedAt as tooltip title', () => {
expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm UTC');
createComponent();
expect(wrapper.find('time').attributes('title')).toBe('Jan 24, 2018 1:02pm UTC');
});
});

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rubocop_spec_helper'
require_relative '../../../rubocop/cop/redis_queue_usage'
RSpec.describe RuboCop::Cop::RedisQueueUsage do
let(:msg) { described_class::MSG }
context 'when assigning Gitlab::Redis::Queues as a variable' do
it 'registers offence for any variable assignment' do
expect_offense(<<~PATTERN)
x = Gitlab::Redis::Queues
^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignment' do
expect_offense(<<~PATTERN)
X = Gitlab::Redis::Queues
^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
end
context 'when assigning Gitlab::Redis::Queues as a part of an array' do
it 'registers offence for variable assignments' do
expect_offense(<<~PATTERN)
x = [ Gitlab::Redis::Cache, Gitlab::Redis::Queues, Gitlab::Redis::SharedState ]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments' do
expect_offense(<<~PATTERN)
ALL = [ Gitlab::Redis::Cache, Gitlab::Redis::Queues, Gitlab::Redis::SharedState ]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments while invoking function' do
expect_offense(<<~PATTERN)
ALL = [ Gitlab::Redis::Cache, Gitlab::Redis::Queues, Gitlab::Redis::SharedState ].freeze
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments while invoking multiple functions' do
expect_offense(<<~PATTERN)
ALL = [ Gitlab::Redis::Cache, Gitlab::Redis::Queues, Gitlab::Redis::SharedState ].foo.freeze
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
end
context 'when assigning Gitlab::Redis::Queues as a part of a hash' do
it 'registers offence for variable assignments' do
expect_offense(<<~PATTERN)
x = { "test": Gitlab::Redis::Queues, "test2": Gitlab::Redis::SharedState }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments' do
expect_offense(<<~PATTERN)
ALL = { "test": Gitlab::Redis::Queues, "test2": Gitlab::Redis::SharedState }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments while invoking function' do
expect_offense(<<~PATTERN)
ALL = { "test": Gitlab::Redis::Queues, "test2": Gitlab::Redis::SharedState }.freeze
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for constant assignments while invoking multiple functions' do
expect_offense(<<~PATTERN)
ALL = { "test": Gitlab::Redis::Queues, "test2": Gitlab::Redis::SharedState }.foo.freeze
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
end
it 'registers offence for any invocation of Gitlab::Redis::Queues methods' do
expect_offense(<<~PATTERN)
Gitlab::Redis::Queues.params
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
it 'registers offence for using Gitlab::Redis::Queues as parameter in method calls' do
expect_offense(<<~PATTERN)
use_redis(Gitlab::Redis::Queues)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
PATTERN
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rubocop_spec_helper'
require_relative '../../../rubocop/cop/sidekiq_redis_call'
RSpec.describe RuboCop::Cop::SidekiqRedisCall do
it 'flags any use of Sidekiq.redis even without blocks' do
expect_offense(<<~PATTERN)
Sidekiq.redis
^^^^^^^^^^^^^ Refrain from directly using Sidekiq.redis unless for migration. For admin operations, use Sidekiq APIs.
PATTERN
end
it 'flags the use of Sidekiq.redis in single-line blocks' do
expect_offense(<<~PATTERN)
Sidekiq.redis { |redis| yield redis }
^^^^^^^^^^^^^ Refrain from directly using Sidekiq.redis unless for migration. For admin operations, use Sidekiq APIs.
PATTERN
end
it 'flags the use of Sidekiq.redis in multi-line blocks' do
expect_offense(<<~PATTERN)
Sidekiq.redis do |conn|
^^^^^^^^^^^^^ Refrain from directly using Sidekiq.redis unless for migration. For admin operations, use Sidekiq APIs.
conn.sadd('queues', queues)
end
PATTERN
end
end

View File

@ -7,7 +7,7 @@ require (
github.com/BurntSushi/toml v1.2.0
github.com/FZambia/sentinel v1.1.1
github.com/alecthomas/chroma/v2 v2.3.0
github.com/aws/aws-sdk-go v1.44.107
github.com/aws/aws-sdk-go v1.44.109
github.com/disintegration/imaging v1.6.2
github.com/getsentry/raven-go v0.2.0
github.com/golang-jwt/jwt/v4 v4.4.2

View File

@ -179,8 +179,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.107 h1:VP7Rq3wzsOV7wrfHqjAAKRksD4We58PaoVSDPKhm8nw=
github.com/aws/aws-sdk-go v1.44.107/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.109 h1:+Na5JPeS0kiEHoBp5Umcuuf+IDqXqD0lXnM920E31YI=
github.com/aws/aws-sdk-go v1.44.109/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA=
github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1 h1:SdK4Ppk5IzLs64ZMvr6MrSficMtjY2oS0WOORXTlxwU=