Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-03 21:11:13 +00:00
parent 9578c9f9e8
commit 63b3a14f15
41 changed files with 734 additions and 205 deletions

View File

@ -1,11 +1,12 @@
<script>
import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
import ProjectsTokenSelector from './projects_token_selector.vue';
export default {
name: 'ProjectsField',
ALL_PROJECTS: 'ALL_PROJECTS',
SELECTED_PROJECTS: 'SELECTED_PROJECTS',
components: { GlFormGroup, GlFormRadio, GlFormText },
components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
props: {
inputAttrs: {
type: Object,
@ -15,8 +16,24 @@ export default {
data() {
return {
selectedRadio: this.$options.ALL_PROJECTS,
selectedProjects: [],
};
},
computed: {
allProjectsRadioSelected() {
return this.selectedRadio === this.$options.ALL_PROJECTS;
},
hiddenInputValue() {
return this.allProjectsRadioSelected
? null
: this.selectedProjects.map((project) => project.id).join(',');
},
},
methods: {
handleTokenSelectorFocus() {
this.selectedRadio = this.$options.SELECTED_PROJECTS;
},
},
};
</script>
@ -32,7 +49,8 @@ export default {
<gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
__('Selected projects')
}}</gl-form-radio>
<input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" />
<input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
<projects-token-selector v-model="selectedProjects" @focus="handleTokenSelectorFocus" />
</gl-form-group>
</div>
</template>

View File

@ -0,0 +1,141 @@
<script>
import {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
const DEBOUNCE_DELAY = 250;
const PROJECTS_PER_PAGE = 20;
export default {
name: 'ProjectsTokenSelector',
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
},
model: {
prop: 'selectedProjects',
},
props: {
selectedProjects: {
type: Array,
required: true,
},
},
apollo: {
projects: {
query: getProjectsQuery,
debounce: DEBOUNCE_DELAY,
variables() {
return {
search: this.searchQuery,
after: null,
first: PROJECTS_PER_PAGE,
};
},
update({ projects }) {
return {
list: projects.nodes.map((project) => ({
...project,
id: getIdFromGraphQLId(project.id),
})),
pageInfo: projects.pageInfo,
};
},
result() {
this.isLoadingMoreProjects = false;
this.isSearching = false;
},
},
},
data() {
return {
projects: {
list: [],
pageInfo: {},
},
searchQuery: '',
isLoadingMoreProjects: false,
isSearching: false,
};
},
methods: {
handleSearch(query) {
this.isSearching = true;
this.searchQuery = query;
},
loadMoreProjects() {
this.isLoadingMoreProjects = true;
this.$apollo.queries.projects.fetchMore({
variables: {
after: this.projects.pageInfo.endCursor,
first: PROJECTS_PER_PAGE,
},
updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
const { projects: previousProjects } = previousResult;
return produce(previousResult, (draftData) => {
/* eslint-disable no-param-reassign */
draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
draftData.projects.pageInfo = newProjects.pageInfo;
/* eslint-enable no-param-reassign */
});
},
});
},
},
};
</script>
<template>
<div class="gl-relative">
<gl-token-selector
:selected-tokens="selectedProjects"
:dropdown-items="projects.list"
:loading="isSearching"
:placeholder="__('Select projects')"
menu-class="gl-w-full! gl-max-w-full!"
@input="$emit('input', $event)"
@focus="$emit('focus', $event)"
@text-input="handleSearch"
@keydown.enter.prevent
>
<template #token-content="{ token: project }">
<gl-avatar
:entity-id="project.id"
:entity-name="project.name"
:src="project.avatarUrl"
:size="16"
/>
{{ project.nameWithNamespace }}
</template>
<template #dropdown-item-content="{ dropdownItem: project }">
<gl-avatar-labeled
:entity-id="project.id"
:entity-name="project.name"
:size="32"
:src="project.avatarUrl"
:label="project.name"
:sub-label="project.nameWithNamespace"
/>
</template>
<template #dropdown-footer>
<gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
<gl-loading-icon v-if="isLoadingMoreProjects" size="md" />
</gl-intersection-observer>
</template>
</gl-token-selector>
</div>
</template>

View File

@ -0,0 +1,22 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getProjects($search: String!, $after: String = "", $first: Int!) {
projects(
search: $search
after: $after
first: $first
membership: true
searchNamespaces: true
sort: "UPDATED_ASC"
) {
nodes {
id
name
nameWithNamespace
avatarUrl
}
pageInfo {
...PageInfo
}
}
}

View File

@ -1,4 +1,6 @@
import Vue from 'vue';
import createFlash from '~/flash';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue';
@ -43,17 +45,46 @@ export const initProjectsField = () => {
const inputAttrs = getInputAttrs(el);
if (window.gon.features.personalAccessTokensScopedToProjects) {
const ProjectsField = () => import('./components/projects_field.vue');
return new Promise((resolve) => {
Promise.all([
import('./components/projects_field.vue'),
import('vue-apollo'),
import('~/lib/graphql'),
])
.then(
([
{ default: ProjectsField },
{ default: VueApollo },
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
render(h) {
return h(ProjectsField, {
props: {
inputAttrs,
Vue.use(VueApollo);
resolve(
new Vue({
el,
apolloProvider,
render(h) {
return h(ProjectsField, {
props: {
inputAttrs,
},
});
},
}),
);
},
)
.catch(() => {
createFlash({
message: __(
'An error occurred while loading the access tokens form, please try again.',
),
});
});
},
});
}

View File

@ -3,21 +3,19 @@ import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
const $title = $('#key_title');
const comment = $(this)
.val()
.match(/^\S+ \S+ (.+)\n?$/);
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
const $title = $('#key_title');
const comment = $(this)
.val()
.match(/^\S+ \S+ (.+)\n?$/);
// Extract the SSH Key title from its comment
if (comment && comment.length > 1) {
$title.val(comment[1]).change();
}
});
new Profile(); // eslint-disable-line no-new
initSearchSettings();
// Extract the SSH Key title from its comment
if (comment && comment.length > 1) {
$title.val(comment[1]).change();
}
});
new Profile(); // eslint-disable-line no-new
initSearchSettings();

View File

@ -237,6 +237,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
[
*::ApplicationSettingsHelper.visible_attributes,
*::ApplicationSettingsHelper.external_authorization_service_attributes,
*ApplicationSetting.repository_storages_weighted_attributes,
*ApplicationSetting.kroki_formats_attributes.keys.map { |key| "kroki_formats_#{key}".to_sym },
:lets_encrypt_notification_email,
:lets_encrypt_terms_of_service_accepted,
@ -247,8 +248,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:default_branch_name,
disabled_oauth_sign_in_sources: [],
import_sources: [],
restricted_visibility_levels: [],
repository_storages_weighted: {}
repository_storages: [],
restricted_visibility_levels: []
]
end

View File

@ -90,21 +90,13 @@ class ProjectsController < Projects::ApplicationController
# Refresh the repo in case anything changed
@repository = @project.repository
respond_to do |format|
if result[:status] == :success
flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
format.html do
redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings'))
end
else
flash[:alert] = result[:message]
@project.reset
format.html { render_edit }
end
format.js
if result[:status] == :success
flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings'))
else
flash[:alert] = result[:message]
@project.reset
render 'edit'
end
end

View File

@ -37,8 +37,13 @@ module ApplicationSettingsHelper
end
def storage_weights
Gitlab.config.repositories.storages.keys.each_with_object(OpenStruct.new) do |storage, weights|
weights[storage.to_sym] = @application_setting.repository_storages_weighted[storage] || 0
ApplicationSetting.repository_storages_weighted_attributes.map do |attribute|
storage = attribute.to_s.delete_prefix('repository_storages_weighted_')
{
name: attribute,
label: storage,
value: @application_setting.repository_storages_weighted[storage] || 0
}
end
end

View File

@ -25,6 +25,10 @@ class ApplicationSetting < ApplicationRecord
alias_attribute :instance_group_id, :instance_administrators_group_id
alias_attribute :instance_administrators_group, :instance_group
def self.repository_storages_weighted_attributes
@repository_storages_weighted_atributes ||= Gitlab.config.repositories.storages.keys.map { |k| "repository_storages_weighted_#{k}".to_sym }.freeze
end
def self.kroki_formats_attributes
{
blockdiag: {
@ -40,6 +44,7 @@ class ApplicationSetting < ApplicationRecord
end
store_accessor :kroki_formats, *ApplicationSetting.kroki_formats_attributes.keys, prefix: true
store_accessor :repository_storages_weighted, *Gitlab.config.repositories.storages.keys, prefix: true
# Include here so it can override methods from
# `add_authentication_token_field`
@ -498,7 +503,6 @@ class ApplicationSetting < ApplicationRecord
inclusion: { in: [true, false], message: _('must be a boolean value') }
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@ -579,6 +583,12 @@ class ApplicationSetting < ApplicationRecord
recaptcha_enabled || login_recaptcha_protection_enabled
end
repository_storages_weighted_attributes.each do |attribute|
define_method :"#{attribute}=" do |value|
super(value.to_i)
end
end
kroki_formats_attributes.keys.each do |key|
define_method :"kroki_formats_#{key}=" do |value|
super(::Gitlab::Utils.to_boolean(value))

View File

@ -298,6 +298,10 @@ module ApplicationSettingImplementation
Array(read_attribute(:repository_storages))
end
def repository_storages_weighted
read_attribute(:repository_storages_weighted)
end
def commit_email_hostname
super.presence || self.class.default_commit_email_hostname
end
@ -329,10 +333,9 @@ module ApplicationSettingImplementation
def normalized_repository_storage_weights
strong_memoize(:normalized_repository_storage_weights) do
repository_storages_weights = repository_storages_weighted.slice(*Gitlab.config.repositories.storages.keys)
weights_total = repository_storages_weights.values.reduce(:+)
weights_total = repository_storages_weighted.values.reduce(:+)
repository_storages_weights.transform_values do |w|
repository_storages_weighted.transform_values do |w|
next w if weights_total == 0
w.to_f / weights_total
@ -470,20 +473,16 @@ module ApplicationSettingImplementation
invalid.empty?
end
def coerce_repository_storages_weighted
repository_storages_weighted.transform_values!(&:to_i)
end
def check_repository_storages_weighted
invalid = repository_storages_weighted.keys - Gitlab.config.repositories.storages.keys
errors.add(:repository_storages_weighted, _("can't include: %{invalid_storages}") % { invalid_storages: invalid.join(", ") }) unless
errors.add(:repository_storages_weighted, "can't include: %{invalid_storages}" % { invalid_storages: invalid.join(", ") }) unless
invalid.empty?
repository_storages_weighted.each do |key, val|
next unless val.present?
errors.add(:repository_storages_weighted, _("value for '%{storage}' must be an integer") % { storage: key }) unless val.is_a?(Integer)
errors.add(:repository_storages_weighted, _("value for '%{storage}' must be between 0 and 100") % { storage: key }) unless val.between?(0, 100)
errors.add(:"repository_storages_weighted_#{key}", "value must be an integer") unless val.is_a?(Integer)
errors.add(:"repository_storages_weighted_#{key}", "value must be between 0 and 100") unless val.between?(0, 100)
end
end

View File

@ -191,8 +191,12 @@ class MergeRequest < ApplicationRecord
end
state_machine :merge_status, initial: :unchecked do
event :mark_as_preparing do
transition unchecked: :preparing
end
event :mark_as_unchecked do
transition [:can_be_merged, :checking, :unchecked] => :unchecked
transition [:preparing, :can_be_merged, :checking, :unchecked] => :unchecked
transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
end
@ -237,7 +241,7 @@ class MergeRequest < ApplicationRecord
# Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking`
# to avoid exposing unnecessary internal state
def public_merge_status
cannot_be_merged_rechecking? ? 'checking' : merge_status
cannot_be_merged_rechecking? || preparing? ? 'checking' : merge_status
end
validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
@ -1054,6 +1058,8 @@ class MergeRequest < ApplicationRecord
end
def mergeable?(skip_ci_check: false, skip_discussions_check: false)
return false if preparing?
return false unless mergeable_state?(skip_ci_check: skip_ci_check,
skip_discussions_check: skip_discussions_check)

View File

@ -3,6 +3,13 @@
module MergeRequests
class AfterCreateService < MergeRequests::BaseService
def execute(merge_request)
prepare_merge_request(merge_request)
merge_request.mark_as_unchecked! if merge_request.preparing?
end
private
def prepare_merge_request(merge_request)
event_service.open_mr(merge_request, current_user)
merge_request_activity_counter.track_create_mr_action(user: current_user)
notification_service.new_merge_request(merge_request, current_user)

View File

@ -14,6 +14,8 @@ module MergeRequests
end
def after_create(issuable)
issuable.mark_as_preparing
# Add new items to MergeRequests::AfterCreateService if they can
# be performed in Sidekiq
NewMergeRequestWorker.perform_async(issuable.id, current_user.id)

View File

@ -18,9 +18,8 @@
= _('Enter weights for storages for new repositories.')
= link_to sprite_icon('question-o'), help_page_path('administration/repository_storage_paths')
.form-check
= f.fields_for :repository_storages_weighted, storage_weights do |storage_form|
- Gitlab.config.repositories.storages.keys.each do |storage|
= storage_form.text_field storage, class: 'form-text-input'
= storage_form.label storage, storage, class: 'label-bold form-check-label'
%br
- storage_weights.each do |attribute|
= f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value]
= f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label'
%br
= f.submit _('Save changes'), class: "gl-button btn btn-success qa-save-changes-button"

View File

@ -19,7 +19,7 @@
%p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.')
.settings-content
= form_for @project, remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
= form_for @project, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form
@ -36,7 +36,7 @@
.settings-content
= render_if_exists 'shared/promotions/promote_mr_features'
= form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
= form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
= f.submit _('Save changes'), class: "btn gl-button btn-success rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }

View File

@ -1,6 +1,5 @@
= form_for [@project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
= form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
= form_errors(@project)
%fieldset
.row

View File

@ -1,10 +0,0 @@
- if @project.valid?
:plain
location.href = "#{edit_project_path(@project, anchor: params[:update_section])}";
location.reload();
- else
:plain
$(".flash-container").html("#{escape_javascript(render('errors'))}");
$('.save-project-loader').hide();
$('.project-edit-container').show();
$('.edit-project .js-btn-success-general-project-settings').enable();

View File

@ -0,0 +1,5 @@
---
title: Implement new preparing internal merge_status
merge_request: 54900
author:
type: other

View File

@ -0,0 +1,4 @@
title: Bump auto-deploy-image tag in Deploy.latest.gitlab-ci.yml to v2.6.0, which includes changes to ciliumnetworkpolicies.
merge_request: 54983
author:
type: added

View File

@ -1,5 +0,0 @@
---
title: Allow saving repository weights after a storage has been removed
merge_request: 53803
author:
type: fixed

View File

@ -986,6 +986,7 @@ GitLab uses [factory_bot](https://github.com/thoughtbot/factory_bot) as a test f
See [issue #262624](https://gitlab.com/gitlab-org/gitlab/-/issues/262624) for further context.
- Factories don't have to be limited to `ActiveRecord` objects.
[See example](https://gitlab.com/gitlab-org/gitlab-foss/commit/0b8cefd3b2385a21cfed779bd659978c0402766d).
- Factories and their traits should produce valid objects that are [verified by specs](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/factories_spec.rb).
### Fixtures

View File

@ -720,8 +720,8 @@ To enable prevent project forking:
access each project's settings, and remove any project, all from the same screen.
- **Webhooks**: Configure [webhooks](../project/integrations/webhooks.md) for your group.
- **Kubernetes cluster integration**: Connect your GitLab group with [Kubernetes clusters](clusters/index.md).
- **Audit Events**: View [Audit Events](../../administration/audit_events.md)
for the group. **(PREMIUM SELF)**
- **Audit Events**: View [Audit Events](../../administration/audit_events.md#group-events)
for the group.
- **Pipelines quota**: Keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group.
- **Integrations**: Configure [integrations](../admin_area/settings/project_integration_management.md) for your group.

View File

@ -140,13 +140,11 @@ To create and add a new Kubernetes cluster to your project, group, or instance:
1. Click **Review policy**.
1. Enter a suitable name for this policy, and click **Create Policy**. You can now close this window.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an EKS management IAM role.
To do so, follow the [Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) instructions
to create a IAM role suitable for managing the AWS EKS cluster's resources on your behalf.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an **EKS IAM role** following the [Amazon EKS cluster IAM role instructions](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html). This role should exist so that Kubernetes clusters managed by Amazon EKS can make calls to other AWS services on your behalf to manage the resources that you use with the service.
In addition to the policies that guide suggests, you must also include the `AmazonEKSClusterPolicy`
policy for this role in order for GitLab to manage the EKS cluster correctly.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM role:
1. From the left panel, select **Roles**.
1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create another IAM role which will be used by GitLab to authenticate with AWS. Follow these steps to create it:
1. On the AWS IAM console, select **Roles** from the left panel.
1. Click **Create role**.
1. Under `Select type of trusted entity`, select **Another AWS account**.
1. Enter the Account ID from GitLab into the `Account ID` field.

View File

@ -11,13 +11,5 @@ module Bitbucket
lazy
end
def method_missing(method, *args)
return super unless self.respond_to?(method)
self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
block_given? ? yield(item) : item
end
end
end
end

View File

@ -35,13 +35,5 @@ module BitbucketServer
current_page + 1
end
def method_missing(method, *args)
return super unless self.respond_to?(method)
self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
block_given? ? yield(item) : item
end
end
end
end

View File

@ -1,5 +1,5 @@
.auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.0.0"
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.6.0"
dependencies: []
review:

View File

@ -3467,6 +3467,9 @@ msgstr ""
msgid "An error occurred while loading project creation UI"
msgstr ""
msgid "An error occurred while loading the access tokens form, please try again."
msgstr ""
msgid "An error occurred while loading the data. Please try again."
msgstr ""
@ -34923,9 +34926,6 @@ msgstr ""
msgid "can't be enabled because signed commits are required for this project"
msgstr ""
msgid "can't include: %{invalid_storages}"
msgstr ""
msgid "cannot be a date in the past"
msgstr ""
@ -36332,12 +36332,6 @@ msgstr ""
msgid "v%{version} published %{timeAgo}"
msgstr ""
msgid "value for '%{storage}' must be an integer"
msgstr ""
msgid "value for '%{storage}' must be between 0 and 100"
msgstr ""
msgid "verify ownership"
msgstr ""

View File

@ -144,10 +144,10 @@ RSpec.describe Admin::ApplicationSettingsController do
end
it 'updates repository_storages_weighted setting' do
put :update, params: { application_setting: { repository_storages_weighted: { default: 75 } } }
put :update, params: { application_setting: { repository_storages_weighted_default: 75 } }
expect(response).to redirect_to(general_admin_application_settings_path)
expect(ApplicationSetting.current.repository_storages_weighted).to eq('default' => 75)
expect(ApplicationSetting.current.repository_storages_weighted_default).to eq(75)
end
it 'updates kroki_formats setting' do

View File

@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'factories' do
include Database::DatabaseHelpers
# https://gitlab.com/groups/gitlab-org/-/epics/5464 tracks the remaining
# skipped traits.
#
# Consider adding a code comment if a trait cannot produce a valid object.
def skipped_traits
[
[:audit_event, :unauthenticated],

View File

@ -384,20 +384,7 @@ RSpec.describe 'Admin updates settings' do
click_button 'Save changes'
end
expect(current_settings.repository_storages_weighted).to eq('default' => 50)
end
it 'still saves when settings are outdated' do
current_settings.update_attribute :repository_storages_weighted, { 'default' => 100, 'outdated' => 100 }
visit repository_admin_application_settings_path
page.within('.as-repository-storage') do
fill_in 'application_setting_repository_storages_weighted_default', with: 50
click_button 'Save changes'
end
expect(current_settings.repository_storages_weighted).to eq('default' => 50)
expect(current_settings.repository_storages_weighted_default).to be 50
end
end

View File

@ -1,6 +1,7 @@
import { within } from '@testing-library/dom';
import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import ProjectsField from '~/access_tokens/components/projects_field.vue';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
describe('ProjectsField', () => {
let wrapper;
@ -18,6 +19,10 @@ describe('ProjectsField', () => {
const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text);
const queryByText = (text) => within(wrapper.element).queryByText(text);
const findAllProjectsRadio = () => queryByLabelText('All projects');
const findSelectedProjectsRadio = () => queryByLabelText('Selected projects');
const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
beforeEach(() => {
createComponent();
@ -34,25 +39,66 @@ describe('ProjectsField', () => {
});
it('renders "All projects" radio selected by default', () => {
const allProjectsRadio = queryByLabelText('All projects');
const allProjectsRadio = findAllProjectsRadio();
expect(allProjectsRadio).not.toBe(null);
expect(allProjectsRadio.checked).toBe(true);
});
it('renders "Selected projects" radio unchecked by default', () => {
const selectedProjectsRadio = queryByLabelText('Selected projects');
const selectedProjectsRadio = findSelectedProjectsRadio();
expect(selectedProjectsRadio).not.toBe(null);
expect(selectedProjectsRadio.checked).toBe(false);
});
it('renders `projects-token-selector` component', () => {
expect(findProjectsTokenSelector().exists()).toBe(true);
});
it('renders hidden input with correct `name` and `id` attributes', () => {
expect(wrapper.find('input[type="hidden"]').attributes()).toEqual(
expect(findHiddenInput().attributes()).toEqual(
expect.objectContaining({
id: 'projects',
name: 'projects',
}),
);
});
describe('when `projects-token-selector` is focused', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('focus');
});
it('auto selects the "Selected projects" radio', () => {
expect(findSelectedProjectsRadio().checked).toBe(true);
});
describe('when `projects-token-selector` is changed', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('input', [
{
id: 1,
},
{
id: 2,
},
]);
});
it('updates the hidden input value to a comma separated list of project IDs', () => {
expect(findHiddenInput().attributes('value')).toBe('1,2');
});
describe('when radio is changed back to "All projects"', () => {
beforeEach(() => {
fireEvent.click(findAllProjectsRadio());
});
it('removes the hidden input value', () => {
expect(findHiddenInput().attributes('value')).toBe('');
});
});
});
});
});

View File

@ -0,0 +1,224 @@
import {
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlToken,
GlTokenSelector,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { getJSONFixture } from 'helpers/fixtures';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
describe('ProjectsTokenSelector', () => {
const getProjectsQueryResponse = getJSONFixture(
'graphql/projects/access_tokens/get_projects.query.graphql.json',
);
const getProjectsQueryResponsePage2 = produce(
getProjectsQueryResponse,
(getProjectsQueryResponseDraft) => {
/* eslint-disable no-param-reassign */
getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false;
getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null;
getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1);
getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100';
/* eslint-enable no-param-reassign */
},
);
const runDebounce = () => jest.runAllTimers();
const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects;
const project1 = projects[0];
const project2 = projects[1];
let wrapper;
let resolveGetProjectsQuery;
const getProjectsQueryRequestHandler = jest.fn(
() =>
new Promise((resolve) => {
resolveGetProjectsQuery = resolve;
}),
);
const createComponent = ({
propsData = {},
apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]),
resolveQueries = true,
} = {}) => {
Vue.use(VueApollo);
wrapper = extendedWrapper(
mount(ProjectsTokenSelector, {
apolloProvider,
propsData: {
selectedProjects: [],
...propsData,
},
stubs: ['gl-intersection-observer'],
}),
);
runDebounce();
if (resolveQueries) {
resolveGetProjectsQuery(getProjectsQueryResponse);
return waitForPromises();
}
return Promise.resolve();
};
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
it('renders dropdown items with project avatars', async () => {
await createComponent();
wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => {
const project = projects[index];
expect(avatarLabeledWrapper.attributes()).toEqual(
expect.objectContaining({
'entity-id': `${getIdFromGraphQLId(project.id)}`,
'entity-name': project.name,
...(project.avatarUrl && { src: project.avatarUrl }),
}),
);
expect(avatarLabeledWrapper.props()).toEqual(
expect.objectContaining({
label: project.name,
subLabel: project.nameWithNamespace,
}),
);
});
});
it('renders tokens with project avatars', () => {
createComponent({
propsData: {
selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }],
},
});
const token = wrapper.findComponent(GlToken);
const avatar = token.findComponent(GlAvatar);
expect(token.text()).toContain(project2.nameWithNamespace);
expect(avatar.attributes('src')).toBe(project2.avatarUrl);
expect(avatar.props()).toEqual(
expect.objectContaining({
entityId: getIdFromGraphQLId(project2.id),
entityName: project2.name,
}),
);
});
describe('when `enter` key is pressed', () => {
it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => {
createComponent();
const event = {
preventDefault: jest.fn(),
};
findTokenSelectorInput().trigger('keydown.enter', event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('when text input is typed in', () => {
const searchTerm = 'foo bar';
beforeEach(async () => {
await createComponent();
await findTokenSelectorInput().setValue(searchTerm);
runDebounce();
});
it('makes GraphQL request with `search` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
search: searchTerm,
after: null,
first: 20,
});
});
it('sets loading state while waiting for GraphQL request to resolve', async () => {
expect(findTokenSelector().props('loading')).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponse);
await waitForPromises();
expect(findTokenSelector().props('loading')).toBe(false);
});
});
describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => {
beforeEach(async () => {
await createComponent();
findIntersectionObserver().vm.$emit('appear');
});
it('makes GraphQL request with `after` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
after: pageInfo.endCursor,
first: 20,
search: '',
});
});
it('displays loading icon while waiting for GraphQL request to resolve', async () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
});
});
describe('when there is not a next page of projects', () => {
it('does not render `GlIntersectionObserver`', async () => {
createComponent({ resolveQueries: false });
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('when `GlTokenSelector` emits `input` event', () => {
it('emits `input` event used by `v-model`', () => {
findTokenSelector().vm.$emit('input', project1);
expect(wrapper.emitted('input')[0]).toEqual([project1]);
});
});
describe('when `GlTokenSelector` emits `focus` event', () => {
it('emits `focus` event', () => {
const event = { fakeEvent: 'foo' };
findTokenSelector().vm.$emit('focus', event);
expect(wrapper.emitted('focus')[0]).toEqual([event]);
});
});
});

View File

@ -1,19 +1,27 @@
import { createWrapper } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import Vue from 'vue';
import { initExpiresAtField, initProjectsField } from '~/access_tokens';
import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
import ProjectsField from '~/access_tokens/components/projects_field.vue';
import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
describe('access tokens', () => {
const FakeComponent = Vue.component('FakeComponent', {
props: {
inputAttrs: {
type: Object,
required: true,
},
},
render: () => null,
});
beforeEach(() => {
window.gon = { features: { personalAccessTokensScopedToProjects: true } };
});
afterEach(() => {
document.body.innerHTML = '';
window.gon = {};
});
describe.each`
@ -34,15 +42,17 @@ describe('access tokens', () => {
mountEl.appendChild(input);
document.body.appendChild(mountEl);
// Mock component so we don't have to deal with mocking Apollo
// eslint-disable-next-line no-param-reassign
expectedComponent.default = FakeComponent;
});
it(`mounts component and sets \`inputAttrs\` prop`, async () => {
const wrapper = createWrapper(initFunction());
it('mounts component and sets `inputAttrs` prop', async () => {
const vueInstance = await initFunction();
// Wait for dynamic imports to resolve
await waitForPromises();
const component = wrapper.findComponent(expectedComponent);
const wrapper = createWrapper(vueInstance);
const component = wrapper.findComponent(FakeComponent);
expect(component.exists()).toBe(true);
expect(component.props('inputAttrs')).toEqual({

View File

@ -3,13 +3,14 @@
require 'spec_helper'
RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
include ApiHelpers
include JavaScriptFixturesHelpers
runners_token = 'runnerstoken:intabulasreferre'
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) }
let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') }
let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) }
let(:user) { project.owner }
@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
before do
project_with_repo.add_maintainer(user)
sign_in(user)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
end
after do
@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
describe GraphQL::Query, type: :request do
include GraphqlHelpers
context 'access token projects query' do
before do
project_variable_populated.add_maintainer(user)
end
before(:all) do
clean_frontend_fixtures('graphql/projects/access_tokens')
end
fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql']
base_input_path = 'access_tokens/graphql/queries/'
base_output_path = 'graphql/projects/access_tokens/'
query_name = 'get_projects.query.graphql'
it "#{base_output_path}#{query_name}.json" do
query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths)
post_graphql(query, current_user: user, variables: { search: '', first: 2 })
expect_graphql_errors_to_be_empty
end
end
end
end

View File

@ -130,15 +130,20 @@ RSpec.describe ApplicationSettingsHelper do
before do
helper.instance_variable_set(:@application_setting, application_setting)
stub_storage_settings({ 'default': {}, 'storage_1': {}, 'storage_2': {} })
allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return(
[:repository_storages_weighted_default,
:repository_storages_weighted_storage_1,
:repository_storages_weighted_storage_2])
stub_application_setting(repository_storages_weighted: { 'default' => 100, 'storage_1' => 50, 'storage_2' => nil })
end
it 'returns storages correctly' do
expect(helper.storage_weights).to eq(OpenStruct.new(
default: 100,
storage_1: 50,
storage_2: 0
))
expect(helper.storage_weights).to eq([
{ name: :repository_storages_weighted_default, label: 'default', value: 100 },
{ name: :repository_storages_weighted_storage_1, label: 'storage_1', value: 50 },
{ name: :repository_storages_weighted_storage_2, label: 'storage_2', value: 0 }
])
end
end

View File

@ -105,14 +105,14 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) }
it { is_expected.to allow_value('default' => 0).for(:repository_storages_weighted) }
it { is_expected.to allow_value('default' => 50).for(:repository_storages_weighted) }
it { is_expected.to allow_value('default' => 100).for(:repository_storages_weighted) }
it { is_expected.to allow_value('default' => '90').for(:repository_storages_weighted) }
it { is_expected.to allow_value('default' => nil).for(:repository_storages_weighted) }
it { is_expected.not_to allow_value('default' => -1).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") }
it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") }
it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") }
it { is_expected.not_to allow_value(101).for(:repository_storages_weighted_default) }
it { is_expected.to allow_value('90').for(:repository_storages_weighted_default) }
it { is_expected.not_to allow_value(-1).for(:repository_storages_weighted_default) }
it { is_expected.to allow_value(100).for(:repository_storages_weighted_default) }
it { is_expected.to allow_value(0).for(:repository_storages_weighted_default) }
it { is_expected.to allow_value(50).for(:repository_storages_weighted_default) }
it { is_expected.to allow_value(nil).for(:repository_storages_weighted_default) }
it { is_expected.not_to allow_value({ default: 100, shouldntexist: 50 }).for(:repository_storages_weighted) }
it { is_expected.to allow_value(400).for(:notes_create_limit) }
it { is_expected.not_to allow_value('two').for(:notes_create_limit) }
@ -984,6 +984,12 @@ RSpec.describe ApplicationSetting do
it_behaves_like 'application settings examples'
describe 'repository_storages_weighted_attributes' do
it 'returns the keys for repository_storages_weighted' do
expect(subject.class.repository_storages_weighted_attributes).to eq([:repository_storages_weighted_default])
end
end
describe 'kroki_format_supported?' do
it 'returns true when Excalidraw is enabled' do
subject.kroki_formats_excalidraw = true
@ -1027,4 +1033,11 @@ RSpec.describe ApplicationSetting do
expect(subject.kroki_formats_excalidraw).to eq(true)
end
end
it 'does not allow to set weight for non existing storage' do
setting.repository_storages_weighted = { invalid_storage: 100 }
expect(setting).not_to be_valid
expect(setting.errors.messages[:repository_storages_weighted]).to match_array(["can't include: invalid_storage"])
end
end

View File

@ -2885,6 +2885,11 @@ RSpec.describe MergeRequest, factory_default: :keep do
describe '#mergeable?' do
subject { build_stubbed(:merge_request) }
it 'returns false if still preparing' do
expect(subject).to receive(:preparing?) { true }
expect(subject.mergeable?).to be_falsey
end
it 'returns false if #mergeable_state? is false' do
expect(subject).to receive(:mergeable_state?) { false }
@ -3075,6 +3080,7 @@ RSpec.describe MergeRequest, factory_default: :keep do
subject { build(:merge_request, merge_status: status) }
where(:status, :public_status) do
'preparing' | 'checking'
'cannot_be_merged_rechecking' | 'checking'
'checking' | 'checking'
'cannot_be_merged' | 'cannot_be_merged'

View File

@ -67,5 +67,27 @@ RSpec.describe MergeRequests::AfterCreateService do
it_behaves_like 'records an onboarding progress action', :merge_request_created do
let(:namespace) { merge_request.target_project.namespace }
end
context 'when merge request is in unchecked state' do
before do
merge_request.mark_as_unchecked!
execute_service
end
it 'does not change its state' do
expect(merge_request.reload).to be_unchecked
end
end
context 'when merge request is in preparing state' do
before do
merge_request.mark_as_preparing!
execute_service
end
it 'marks the merge request as unchecked' do
expect(merge_request.reload).to be_unchecked
end
end
end
end

View File

@ -67,6 +67,10 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
expect(Event.where(attributes).count).to eq(1)
end
it 'sets the merge_status to preparing' do
expect(merge_request.reload).to be_preparing
end
describe 'when marked with /wip' do
context 'in title and in description' do
let(:opts) do

View File

@ -289,7 +289,6 @@ RSpec.shared_examples 'application settings examples' do
describe '#pick_repository_storage' do
before do
allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w(default backup))
allow(setting).to receive(:repository_storages_weighted).and_return({ 'default' => 20, 'backup' => 80 })
end
@ -305,19 +304,15 @@ RSpec.shared_examples 'application settings examples' do
describe '#normalized_repository_storage_weights' do
using RSpec::Parameterized::TableSyntax
where(:config_storages, :storages, :normalized) do
%w(default backup) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 }
%w(default backup) | { 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 }
%w(default backup) | { 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 }
%w(default backup) | { 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 }
%w(default) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0 }
%w(default) | { 'default' => 100, 'backup' => 100 } | { 'default' => 1.0 }
%w(default) | { 'default' => 20, 'backup' => 80 } | { 'default' => 1.0 }
where(:storages, :normalized) do
{ 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 }
{ 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 }
{ 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 }
{ 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 }
end
with_them do
before do
allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(config_storages)
allow(setting).to receive(:repository_storages_weighted).and_return(storages)
end

View File

@ -3,49 +3,34 @@
require 'spec_helper'
RSpec.describe 'admin/application_settings/_repository_storage.html.haml' do
let(:app_settings) { build(:application_setting, repository_storages_weighted: repository_storages_weighted) }
before do
stub_storage_settings({ 'default': {}, 'mepmep': {}, 'foobar': {} })
assign(:application_setting, app_settings)
let(:app_settings) { create(:application_setting) }
let(:repository_storages_weighted_attributes) { [:repository_storages_weighted_default, :repository_storages_weighted_mepmep, :repository_storages_weighted_foobar]}
let(:repository_storages_weighted) do
{
"default" => 100,
"mepmep" => 50
}
end
context 'additional storage config' do
let(:repository_storages_weighted) do
{
'default' => 100,
'mepmep' => 50
}
end
before do
allow(app_settings).to receive(:repository_storages_weighted).and_return(repository_storages_weighted)
allow(app_settings).to receive(:repository_storages_weighted_mepmep).and_return(100)
allow(app_settings).to receive(:repository_storages_weighted_foobar).and_return(50)
assign(:application_setting, app_settings)
allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return(repository_storages_weighted_attributes)
end
context 'when multiple storages are available' do
it 'lists them all' do
render
Gitlab.config.repositories.storages.keys.each do |storage_name|
# lists storages that are saved with weights
repository_storages_weighted.each do |storage_name, storage_weight|
expect(rendered).to have_content(storage_name)
end
# lists storage not saved with weight
expect(rendered).to have_content('foobar')
end
end
context 'fewer storage configs' do
let(:repository_storages_weighted) do
{
'default' => 100,
'mepmep' => 50,
'something_old' => 100
}
end
it 'lists only configured storages' do
render
Gitlab.config.repositories.storages.keys.each do |storage_name|
expect(rendered).to have_content(storage_name)
end
expect(rendered).not_to have_content('something_old')
end
end
end