Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-12-15 00:08:38 +00:00
parent a64e7a4066
commit 17c478bc80
55 changed files with 1777 additions and 748 deletions

View File

@ -284,7 +284,7 @@ gem 'sanitize', '~> 6.0'
gem 'babosa', '~> 1.0.4'
# Sanitizes SVG input
gem 'loofah', '~> 2.19.0'
gem 'loofah', '~> 2.19.1'
# Working with license
# Detects the open source license the repository includes

View File

@ -322,7 +322,7 @@
{"name":"locale","version":"2.1.3","platform":"ruby","checksum":"b6ddee011e157817cb98e521b3ce7cb626424d5882f1e844aafdee3e8b212725"},
{"name":"lockbox","version":"0.6.2","platform":"ruby","checksum":"0136677875c3d6e27cef87cd7bd66610404e2b3cd7f07f1ac8ed34e48f18dc3c"},
{"name":"lograge","version":"0.11.2","platform":"ruby","checksum":"4cbd1554b86f545d795eff15a0c24fd25057d2ac4e1caa5fc186168b3da932ef"},
{"name":"loofah","version":"2.19.0","platform":"ruby","checksum":"302791371f473611e342f9e469e7f2fbf1155bb1b3a978a83ac7df625298feba"},
{"name":"loofah","version":"2.19.1","platform":"ruby","checksum":"6c6469efdefe3496010000a346f9d3bf710e11ac4661e353cf56852326fb1023"},
{"name":"lookbook","version":"1.2.1","platform":"ruby","checksum":"742844b625798b689215d1660f711aa79ff54084f5e8735fe674fe771fc165d7"},
{"name":"lru_redux","version":"1.1.0","platform":"ruby","checksum":"ee71d0ccab164c51de146c27b480a68b3631d5b4297b8ffe8eda1c72de87affb"},
{"name":"lumberjack","version":"1.2.7","platform":"ruby","checksum":"a5c6aae6b4234f1420dbcd80b23e3bca0817bd239440dde097ebe3fa63c63b1f"},

View File

@ -863,7 +863,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.19.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
lookbook (1.2.1)
@ -1720,7 +1720,7 @@ DEPENDENCIES
listen (~> 3.7)
lockbox (~> 0.6.2)
lograge (~> 0.5)
loofah (~> 2.19.0)
loofah (~> 2.19.1)
lookbook (~> 1.2, >= 1.2.1)
lru_redux
mail (= 2.7.1)

View File

@ -7,7 +7,8 @@
"CiGroupVariable",
"CiInstanceVariable",
"CiManualVariable",
"CiProjectVariable"
"CiProjectVariable",
"PipelineScheduleVariable"
],
"CommitSignature": [
"GpgSignature",

View File

@ -12,3 +12,7 @@ export const STATUSES = {
CANCELLED: 'cancelled',
TIMEOUT: 'timeout',
};
export const PROVIDERS = {
GITHUB: 'github',
};

View File

@ -164,7 +164,7 @@ export default {
}
return this.groups.map((group) => {
const importTarget = this.getImportTarget(group);
const importTarget = this.importTargets[group.id];
const status = this.getStatus(group);
const flags = {
@ -256,10 +256,14 @@ export default {
this.page = 1;
},
groupsTableData() {
groups() {
const table = this.getTableRef();
const matches = new Set();
this.groupsTableData.forEach((g, idx) => {
this.groups.forEach((g, idx) => {
if (!this.importGroups[g.id]) {
this.setDefaultImportTarget(g);
}
if (this.selectedGroupsIds.includes(g.id)) {
matches.add(g.id);
this.$nextTick(() => {
@ -450,11 +454,7 @@ export default {
importTarget.validationErrors = newValidationErrors;
}, VALIDATION_DEBOUNCE_TIME),
getImportTarget(group) {
if (this.importTargets[group.id]) {
return this.importTargets[group.id];
}
setDefaultImportTarget(group) {
// If we've reached this Vue application we have at least one potential import destination
const defaultTargetNamespace =
// first option: namespace id was explicitly provided
@ -515,7 +515,6 @@ export default {
.catch(() => {
// empty catch intended
});
return this.importTargets[group.id];
},
},

View File

@ -203,7 +203,7 @@ export default {
</table>
</div>
<gl-intersection-observer
v-if="paginatable"
v-if="paginatable && pageInfo.hasNextPage"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>

View File

@ -1,4 +1,5 @@
import Visibility from 'visibilityjs';
import _ from 'lodash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@ -8,6 +9,7 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale';
import { isProjectImportable } from '../utils';
import { PROVIDERS } from '../../constants';
import * as types from './mutation_types';
let eTagPoll;
@ -22,6 +24,24 @@ const pathWithParams = ({ path, ...params }) => {
const queryString = objectToQuery(filteredParams);
return queryString ? `${path}?${queryString}` : path;
};
const commitPaginationData = ({ state, commit, data }) => {
const cursorsGitHubResponse = !_.isEmpty(data.pageInfo || {});
if (state.provider === PROVIDERS.GITHUB && cursorsGitHubResponse) {
commit(types.SET_PAGE_CURSORS, data.pageInfo);
} else {
const nextPage = state.pageInfo.page + 1;
commit(types.SET_PAGE, nextPage);
}
};
const paginationParams = ({ state }) => {
if (state.provider === PROVIDERS.GITHUB && state.pageInfo.endCursor) {
return { after: state.pageInfo.endCursor };
}
const nextPage = state.pageInfo.page + 1;
return { page: nextPage === 1 ? '' : nextPage.toString() };
};
const isRequired = () => {
// eslint-disable-next-line @gitlab/require-i18n-strings
@ -55,7 +75,6 @@ const importAll = ({ state, dispatch }, config = {}) => {
};
const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => {
const nextPage = state.pageInfo.page + 1;
commit(types.REQUEST_REPOS);
const { provider, filter } = state;
@ -65,12 +84,13 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit })
pathWithParams({
path: reposPath,
filter: filter ?? '',
page: nextPage === 1 ? '' : nextPage.toString(),
...paginationParams({ state }),
}),
)
.then(({ data }) => {
commit(types.SET_PAGE, nextPage);
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true }));
const camelData = convertObjectPropsToCamelCase(data, { deep: true });
commitPaginationData({ state, commit, data: camelData });
commit(types.RECEIVE_REPOS_SUCCESS, camelData);
})
.catch((e) => {
if (hasRedirectInError(e)) {

View File

@ -14,4 +14,4 @@ export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET';
export const SET_PAGE = 'SET_PAGE';
export const SET_PAGE_INFO = 'SET_PAGE_INFO';
export const SET_PAGE_CURSORS = 'SET_PAGE_CURSORS';

View File

@ -138,4 +138,9 @@ export default {
[types.SET_PAGE](state, page) {
state.pageInfo.page = page;
},
[types.SET_PAGE_CURSORS](state, pageInfo) {
const { startCursor, endCursor, hasNextPage } = pageInfo;
state.pageInfo = { ...state.pageInfo, startCursor, endCursor, hasNextPage };
},
};

View File

@ -7,5 +7,8 @@ export default () => ({
filter: '',
pageInfo: {
page: 0,
startCursor: null,
endCursor: null,
hasNextPage: true,
},
});

View File

@ -1,5 +1,5 @@
<script>
import { GlAlert, GlBadge, GlButton, GlModalDirective, GlForm } from '@gitlab/ui';
import { GlAlert, GlBadge, GlButton, GlForm } from '@gitlab/ui';
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
@ -10,7 +10,6 @@ import {
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
INTEGRATION_FORM_TYPE_SLACK,
integrationLevels,
integrationFormSectionComponents,
billingPlanNames,
} from '~/integrations/constants';
@ -19,11 +18,10 @@ import csrf from '~/lib/utils/csrf';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
import ConfirmationModal from './confirmation_modal.vue';
import DynamicField from './dynamic_field.vue';
import OverrideDropdown from './override_dropdown.vue';
import ResetConfirmationModal from './reset_confirmation_modal.vue';
import TriggerFields from './trigger_fields.vue';
import IntegrationFormActions from './integration_form_actions.vue';
export default {
name: 'IntegrationForm',
@ -32,8 +30,7 @@ export default {
ActiveCheckbox,
TriggerFields,
DynamicField,
ConfirmationModal,
ResetConfirmationModal,
IntegrationFormActions,
IntegrationSectionConfiguration: () =>
import(
/* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue'
@ -60,7 +57,6 @@ export default {
GlForm,
},
directives: {
GlModal: GlModalDirective,
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
@ -72,10 +68,10 @@ export default {
data() {
return {
integrationActive: false,
isTesting: false,
isSaving: false,
isResetting: false,
isValidated: false,
isSaving: false,
isTesting: false,
isResetting: false,
};
},
computed: {
@ -84,21 +80,6 @@ export default {
isEditable() {
return this.propsSource.editable;
},
isInstanceOrGroupLevel() {
return (
this.customState.integrationLevel === integrationLevels.INSTANCE ||
this.customState.integrationLevel === integrationLevels.GROUP
);
},
showResetButton() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
showTestButton() {
return this.propsSource.canTest;
},
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
hasSections() {
if (this.hasSlackNotificationsDisabled) {
return false;
@ -150,7 +131,6 @@ export default {
},
onSaveClick() {
this.isSaving = true;
if (this.integrationActive && !this.form().checkValidity()) {
this.isSaving = false;
this.setIsValidated();
@ -196,7 +176,6 @@ export default {
},
onResetClick() {
this.isResetting = true;
return axios
.post(this.propsSource.resetPath)
.then(() => {
@ -351,71 +330,16 @@ export default {
</div>
</section>
<section v-if="isEditable" :class="!hasSections && 'gl-lg-display-flex gl-justify-content-end'">
<div :class="!hasSections && 'gl-flex-basis-two-thirds'">
<div
class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between"
>
<div>
<template v-if="isInstanceOrGroupLevel">
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
variant="confirm"
:loading="isSaving"
:disabled="disableButtons"
data-testid="save-button-instance-group"
data-qa-selector="save_changes_button"
>
{{ __('Save changes') }}
</gl-button>
<confirmation-modal @submit="onSaveClick" />
</template>
<gl-button
v-else
category="primary"
variant="confirm"
type="submit"
:loading="isSaving"
:disabled="disableButtons"
data-testid="save-button"
data-qa-selector="save_changes_button"
@click.prevent="onSaveClick"
>
{{ __('Save changes') }}
</gl-button>
<gl-button
v-if="showTestButton"
category="secondary"
variant="confirm"
:loading="isTesting"
:disabled="disableButtons"
data-testid="test-button"
@click.prevent="onTestClick"
>
{{ __('Test settings') }}
</gl-button>
<gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button>
</div>
<template v-if="showResetButton">
<gl-button
v-gl-modal.confirmResetIntegration
category="tertiary"
variant="danger"
:loading="isResetting"
:disabled="disableButtons"
data-testid="reset-button"
>
{{ __('Reset') }}
</gl-button>
<reset-confirmation-modal @reset="onResetClick" />
</template>
</div>
</div>
</section>
<integration-form-actions
v-if="isEditable"
:has-sections="hasSections"
:class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }"
:is-saving="isSaving"
:is-testing="isTesting"
:is-resetting="isResetting"
@save="onSaveClick"
@test="onTestClick"
@reset="onResetClick"
/>
</gl-form>
</template>

View File

@ -0,0 +1,143 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { integrationLevels } from '~/integrations/constants';
import ConfirmationModal from './confirmation_modal.vue';
import ResetConfirmationModal from './reset_confirmation_modal.vue';
export default {
name: 'IntegrationFormActions',
components: {
GlButton,
ConfirmationModal,
ResetConfirmationModal,
},
directives: {
GlModal: GlModalDirective,
},
props: {
hasSections: {
type: Boolean,
required: true,
},
isSaving: {
type: Boolean,
required: false,
default: false,
},
isTesting: {
type: Boolean,
required: false,
default: false,
},
isResetting: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapGetters(['propsSource']),
...mapState(['customState']),
isInstanceOrGroupLevel() {
return (
this.customState.integrationLevel === integrationLevels.INSTANCE ||
this.customState.integrationLevel === integrationLevels.GROUP
);
},
showResetButton() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
showTestButton() {
return this.propsSource.canTest;
},
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
},
methods: {
onSaveClick() {
this.$emit('save');
},
onTestClick() {
this.$emit('test');
},
onResetClick() {
this.$emit('reset');
},
},
};
</script>
<template>
<section>
<div :class="{ 'gl-flex-basis-two-thirds': !hasSections }">
<div
class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between"
>
<div>
<template v-if="isInstanceOrGroupLevel">
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
variant="confirm"
:loading="isSaving"
:disabled="disableButtons"
data-testid="save-button"
data-qa-selector="save_changes_button"
>
{{ __('Save changes') }}
</gl-button>
<confirmation-modal @submit="onSaveClick" />
</template>
<gl-button
v-else
category="primary"
variant="confirm"
type="submit"
:loading="isSaving"
:disabled="disableButtons"
data-testid="save-button"
data-qa-selector="save_changes_button"
@click.prevent="onSaveClick"
>
{{ __('Save changes') }}
</gl-button>
<gl-button
v-if="showTestButton"
category="secondary"
variant="confirm"
:loading="isTesting"
:disabled="disableButtons"
data-testid="test-button"
@click.prevent="onTestClick"
>
{{ __('Test settings') }}
</gl-button>
<gl-button
:href="propsSource.cancelPath"
data-testid="cancel-button"
:disabled="disableButtons"
>{{ __('Cancel') }}</gl-button
>
</div>
<template v-if="showResetButton">
<gl-button
v-gl-modal.confirmResetIntegration
category="tertiary"
variant="danger"
:loading="isResetting"
:disabled="disableButtons"
data-testid="reset-button"
>
{{ __('Reset') }}
</gl-button>
<reset-confirmation-modal @reset="onResetClick" />
</template>
</div>
</div>
</section>
</template>

View File

@ -16,12 +16,27 @@ class Import::GiteaController < Import::GithubController
super
end
# We need to re-expose controller's internal method 'status' as action.
# rubocop:disable Lint/UselessMethodDefinition
def status
super
# Request repos to display error page if provider token is invalid
# Improving in https://gitlab.com/gitlab-org/gitlab/-/issues/25859
client_repos
respond_to do |format|
format.json do
render json: { imported_projects: serialized_imported_projects,
provider_repos: serialized_provider_repos,
incompatible_repos: serialized_incompatible_repos }
end
format.html do
if params[:namespace_id].present?
@namespace = Namespace.find_by_id(params[:namespace_id])
render_404 unless current_user.can?(:create_projects, @namespace)
end
end
end
end
# rubocop:enable Lint/UselessMethodDefinition
protected
@ -61,7 +76,6 @@ class Import::GiteaController < Import::GithubController
@client_repos ||= filtered(client.repos)
end
override :client
def client
@client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options)
end

View File

@ -15,6 +15,8 @@ class Import::GithubController < Import::BaseController
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded
delegate :client, to: :client_proxy, private: true
PAGE_LENGTH = 25
def new
@ -46,7 +48,22 @@ class Import::GithubController < Import::BaseController
# Improving in https://gitlab.com/gitlab-org/gitlab-foss/issues/55585
client_repos
super
respond_to do |format|
format.json do
render json: { imported_projects: serialized_imported_projects,
provider_repos: serialized_provider_repos,
incompatible_repos: serialized_incompatible_repos,
page_info: client_repos_response[:page_info] }
end
format.html do
if params[:namespace_id].present?
@namespace = Namespace.find_by_id(params[:namespace_id])
render_404 unless current_user.can?(:create_projects, @namespace)
end
end
end
end
def create
@ -126,24 +143,18 @@ class Import::GithubController < Import::BaseController
end
end
def client
@client ||= if Feature.enabled?(:remove_legacy_github_client)
Gitlab::GithubImport::Client.new(session[access_token_key])
else
Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options)
end
def client_proxy
@client_proxy ||= Gitlab::GithubImport::Clients::Proxy.new(
session[access_token_key], client_options
)
end
def client_repos_response
@client_repos_response ||= client_proxy.repos(sanitized_filter_param, pagination_options)
end
def client_repos
@client_repos ||= if Feature.enabled?(:remove_legacy_github_client)
if sanitized_filter_param
client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items]
else
client.repos(pagination_options)
end
else
filtered(client.repos)
end
client_repos_response[:repos]
end
def sanitized_filter_param
@ -213,6 +224,11 @@ class Import::GithubController < Import::BaseController
def pagination_options
{
before: params[:before].presence,
after: params[:after].presence,
first: PAGE_LENGTH,
# TODO: remove after rollout FF github_client_fetch_repos_via_graphql
# https://gitlab.com/gitlab-org/gitlab/-/issues/385649
page: [1, params[:page].to_i].max,
per_page: PAGE_LENGTH
}

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Mutations
module Ci
module PipelineSchedule
class Create < BaseMutation
graphql_name 'PipelineScheduleCreate'
include FindsProject
authorize :create_pipeline_schedule
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project the pipeline schedule is associated with.'
argument :description, GraphQL::Types::String,
required: true,
description: 'Description of the pipeline schedule.'
argument :cron, GraphQL::Types::String,
required: true,
description: 'Cron expression of the pipeline schedule.'
argument :cron_timezone, GraphQL::Types::String,
required: false,
description:
<<-STR
Cron time zone supported by ActiveSupport::TimeZone.
For example: "Pacific Time (US & Canada)" (default: "UTC").
STR
argument :ref, GraphQL::Types::String,
required: true,
description: 'Ref of the pipeline schedule.'
argument :active, GraphQL::Types::Boolean,
required: false,
description: 'Indicates if the pipeline schedule should be active or not.'
argument :variables, [Mutations::Ci::PipelineSchedule::VariableInputType],
required: false,
description: 'Variables for the pipeline schedule.'
field :pipeline_schedule,
Types::Ci::PipelineScheduleType,
description: 'Created pipeline schedule.'
def resolve(project_path:, variables: [], **pipeline_schedule_attrs)
project = authorized_find!(project_path)
params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h))
schedule = ::Ci::CreatePipelineScheduleService
.new(project, current_user, params)
.execute
unless schedule.persisted?
return {
pipeline_schedule: nil, errors: schedule.errors.full_messages
}
end
{
pipeline_schedule: schedule,
errors: []
}
end
end
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Mutations
module Ci
module PipelineSchedule
class VariableInputType < Types::BaseInputObject
graphql_name 'PipelineScheduleVariableInput'
description 'Attributes for the pipeline schedule variable.'
argument :key, GraphQL::Types::String, required: true, description: 'Name of the variable.'
argument :value, GraphQL::Types::String, required: true, description: 'Value of the variable.'
argument :variable_type, Types::Ci::VariableTypeEnum, required: true, description: 'Type of the variable.'
end
end
end
end

View File

@ -5,6 +5,8 @@ module Types
class PipelineScheduleType < BaseObject
graphql_name 'PipelineSchedule'
description 'Represents a pipeline schedule'
connection_type_class(Types::CountableConnectionType)
expose_permissions Types::PermissionTypes::Ci::PipelineSchedules
@ -17,7 +19,9 @@ module Types
field :owner, ::Types::UserType, null: false, description: 'Owner of the pipeline schedule.'
field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates if a pipeline schedule is active.'
field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates if the pipeline schedule is active.'
field :project, ::Types::ProjectType, null: true, description: 'Project of the pipeline schedule.'
field :next_run_at, Types::TimeType, null: false, description: 'Time when the next pipeline will run.'
@ -26,26 +30,49 @@ module Types
field :last_pipeline, PipelineType, null: true, description: 'Last pipeline object.'
field :ref_for_display, GraphQL::Types::String,
null: true, description: 'Git ref for the pipeline schedule.', method: :ref_for_display
field :ref_path, GraphQL::Types::String, null: true, description: 'Path to the ref that triggered the pipeline.'
null: true, description: 'Git ref for the pipeline schedule.'
field :for_tag, GraphQL::Types::Boolean,
null: false, description: 'Indicates if a pipelines schedule belongs to a tag.', method: :for_tag?
field :cron, GraphQL::Types::String, null: false, description: 'Cron notation for the schedule.'
field :edit_path, GraphQL::Types::String,
null: true,
description: 'Edit path of the pipeline schedule.',
authorize: :update_pipeline_schedule
field :cron_timezone, GraphQL::Types::String, null: false, description: 'Timezone for the pipeline schedule.'
field :variables,
Types::Ci::PipelineScheduleVariableType.connection_type,
null: true,
description: 'Pipeline schedule variables.',
authorize: :read_pipeline_schedule_variables
field :edit_path, GraphQL::Types::String, null: true, description: 'Edit path of the pipeline schedule.'
field :ref, GraphQL::Types::String,
null: true, description: 'Ref of the pipeline schedule.', method: :ref_for_display
field :ref_path, GraphQL::Types::String,
null: true,
description: 'Path to the ref that triggered the pipeline.'
field :cron, GraphQL::Types::String,
null: false,
description: 'Cron notation for the schedule.'
field :cron_timezone, GraphQL::Types::String,
null: false,
description: 'Timezone for the pipeline schedule.'
field :created_at, Types::TimeType,
null: false, description: 'Timestamp of when the pipeline schedule was created.'
field :updated_at, Types::TimeType,
null: false, description: 'Timestamp of when the pipeline schedule was last updated.'
def ref_path
::Gitlab::Routing.url_helpers.project_commits_path(object.project, object.ref_for_display)
end
def edit_path
::Gitlab::Routing.url_helpers.edit_project_pipeline_schedule_path(object.project, object) if Ability.allowed?(
current_user, :update_pipeline_schedule, object)
::Gitlab::Routing.url_helpers.edit_project_pipeline_schedule_path(object.project, object)
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Types
module Ci
class PipelineScheduleVariableType < BaseObject
graphql_name 'PipelineScheduleVariable'
authorize :read_pipeline_schedule_variables
implements(VariableInterface)
end
end
end

View File

@ -119,6 +119,7 @@ module Types
mount_mutation Mutations::Ci::PipelineSchedule::Delete
mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership
mount_mutation Mutations::Ci::PipelineSchedule::Play
mount_mutation Mutations::Ci::PipelineSchedule::Create
mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: {
reason: :renamed,
replacement: 'ProjectCiCdSettingsUpdate',

View File

@ -14,10 +14,12 @@ class ProtectedBranch < ApplicationRecord
scope :allowing_force_push,
-> { where(allow_force_push: true) }
scope :get_ids_by_name, -> (name) { where(name: name).pluck(:id) }
protected_ref_access_levels :merge, :push
def self.get_ids_by_name(name)
where(name: name).pluck(:id)
end
def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
# Maintainers, owners and admins are allowed to create the default branch

View File

@ -53,7 +53,7 @@ class ServiceDeskSetting < ApplicationRecord
def projects_with_same_slug_and_key_exists?
return false unless project_key
settings = self.class.with_project_key(project_key).preload(:project)
settings = self.class.with_project_key(project_key).where.not(project_id: project_id).preload(:project)
project_slug = self.project.full_path_slug
settings.any? do |setting|

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Ci
class PipelineScheduleVariablePolicy < BasePolicy
delegate :pipeline_schedule
end
end

View File

@ -294,10 +294,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'),
empty_repo? ? add_license_ide_path : add_license_path)
else
AnchorData.new(false,
icon + content_tag(:span, _('No license. All rights reserved'), class: 'project-stat-value'),
nil)
end
end

View File

@ -1,4 +1,4 @@
= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f|
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@ -15,4 +15,4 @@
%span.form-text.text-muted#static_objects_external_storage_auth_token_help_block
= _('Secure token that identifies an external storage request.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
= f.submit _('Save changes'), pajamas_button: true

View File

@ -1,4 +1,4 @@
= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f|
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@ -13,4 +13,4 @@
= f.number_field :search_rate_limit_unauthenticated, class: 'form-control gl-form-input'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
= f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true

View File

@ -71,4 +71,4 @@
-# This is added for Jihu edition in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/1112
= render_if_exists 'admin/application_settings/disable_download_button', f: f
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
= f.submit _('Save changes'), pajamas_button: true

View File

@ -1,4 +1,4 @@
= form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f|
= gitlab_ui_form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f|
= form_errors(@identity)
.form-group.row
@ -14,5 +14,5 @@
= f.text_field :extern_uid, class: 'form-control', required: true
.form-actions
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
= f.submit _('Save changes'), pajamas_button: true

View File

@ -1,7 +1,7 @@
%h1.page-title.gl-font-size-h-display
= _('New merge request')
= form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f|
= gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f|
- if params[:nav_source].present?
= hidden_field_tag(:nav_source, params[:nav_source])
.js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
@ -71,4 +71,4 @@
- if @merge_request.errors.any?
= form_errors(@merge_request)
= f.submit _('Compare branches and continue'), class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" }
= f.submit _('Compare branches and continue'), data: { qa_selector: 'compare_branches_button' }, pajamas_button: true

View File

@ -0,0 +1,8 @@
---
name: github_client_fetch_repos_via_graphql
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105824
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/385649
milestone: '15.7'
type: development
group: group::import
default_enabled: false

View File

@ -4292,6 +4292,31 @@ Input type: `PipelineRetryInput`
| <a id="mutationpipelineretryerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationpipelineretrypipeline"></a>`pipeline` | [`Pipeline`](#pipeline) | Pipeline after mutation. |
### `Mutation.pipelineScheduleCreate`
Input type: `PipelineScheduleCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationpipelineschedulecreateactive"></a>`active` | [`Boolean`](#boolean) | Indicates if the pipeline schedule should be active or not. |
| <a id="mutationpipelineschedulecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationpipelineschedulecreatecron"></a>`cron` | [`String!`](#string) | Cron expression of the pipeline schedule. |
| <a id="mutationpipelineschedulecreatecrontimezone"></a>`cronTimezone` | [`String`](#string) | Cron time zone supported by ActiveSupport::TimeZone. For example: "Pacific Time (US & Canada)" (default: "UTC"). |
| <a id="mutationpipelineschedulecreatedescription"></a>`description` | [`String!`](#string) | Description of the pipeline schedule. |
| <a id="mutationpipelineschedulecreateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project the pipeline schedule is associated with. |
| <a id="mutationpipelineschedulecreateref"></a>`ref` | [`String!`](#string) | Ref of the pipeline schedule. |
| <a id="mutationpipelineschedulecreatevariables"></a>`variables` | [`[PipelineScheduleVariableInput!]`](#pipelineschedulevariableinput) | Variables for the pipeline schedule. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationpipelineschedulecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationpipelineschedulecreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationpipelineschedulecreatepipelineschedule"></a>`pipelineSchedule` | [`PipelineSchedule`](#pipelineschedule) | Created pipeline schedule. |
### `Mutation.pipelineScheduleDelete`
Input type: `PipelineScheduleDeleteInput`
@ -8779,6 +8804,29 @@ The edge type for [`PipelineSchedule`](#pipelineschedule).
| <a id="pipelinescheduleedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="pipelinescheduleedgenode"></a>`node` | [`PipelineSchedule`](#pipelineschedule) | The item at the end of the edge. |
#### `PipelineScheduleVariableConnection`
The connection type for [`PipelineScheduleVariable`](#pipelineschedulevariable).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="pipelineschedulevariableconnectionedges"></a>`edges` | [`[PipelineScheduleVariableEdge]`](#pipelineschedulevariableedge) | A list of edges. |
| <a id="pipelineschedulevariableconnectionnodes"></a>`nodes` | [`[PipelineScheduleVariable]`](#pipelineschedulevariable) | A list of nodes. |
| <a id="pipelineschedulevariableconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `PipelineScheduleVariableEdge`
The edge type for [`PipelineScheduleVariable`](#pipelineschedulevariable).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="pipelineschedulevariableedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="pipelineschedulevariableedgenode"></a>`node` | [`PipelineScheduleVariable`](#pipelineschedulevariable) | The item at the end of the edge. |
#### `PipelineSecurityReportFindingConnection`
The connection type for [`PipelineSecurityReportFinding`](#pipelinesecurityreportfinding).
@ -16865,11 +16913,14 @@ Represents pipeline counts for the project.
### `PipelineSchedule`
Represents a pipeline schedule.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="pipelinescheduleactive"></a>`active` | [`Boolean!`](#boolean) | Indicates if a pipeline schedule is active. |
| <a id="pipelinescheduleactive"></a>`active` | [`Boolean!`](#boolean) | Indicates if the pipeline schedule is active. |
| <a id="pipelineschedulecreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp of when the pipeline schedule was created. |
| <a id="pipelineschedulecron"></a>`cron` | [`String!`](#string) | Cron notation for the schedule. |
| <a id="pipelineschedulecrontimezone"></a>`cronTimezone` | [`String!`](#string) | Timezone for the pipeline schedule. |
| <a id="pipelinescheduledescription"></a>`description` | [`String`](#string) | Description of the pipeline schedule. |
@ -16879,10 +16930,14 @@ Represents pipeline counts for the project.
| <a id="pipelineschedulelastpipeline"></a>`lastPipeline` | [`Pipeline`](#pipeline) | Last pipeline object. |
| <a id="pipelineschedulenextrunat"></a>`nextRunAt` | [`Time!`](#time) | Time when the next pipeline will run. |
| <a id="pipelinescheduleowner"></a>`owner` | [`UserCore!`](#usercore) | Owner of the pipeline schedule. |
| <a id="pipelinescheduleproject"></a>`project` | [`Project`](#project) | Project of the pipeline schedule. |
| <a id="pipelineschedulerealnextrun"></a>`realNextRun` | [`Time!`](#time) | Time when the next pipeline will run. |
| <a id="pipelinescheduleref"></a>`ref` | [`String`](#string) | Ref of the pipeline schedule. |
| <a id="pipelineschedulereffordisplay"></a>`refForDisplay` | [`String`](#string) | Git ref for the pipeline schedule. |
| <a id="pipelineschedulerefpath"></a>`refPath` | [`String`](#string) | Path to the ref that triggered the pipeline. |
| <a id="pipelinescheduleupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of when the pipeline schedule was last updated. |
| <a id="pipelinescheduleuserpermissions"></a>`userPermissions` | [`PipelineSchedulePermissions!`](#pipelineschedulepermissions) | Permissions for the current user on the resource. |
| <a id="pipelineschedulevariables"></a>`variables` | [`PipelineScheduleVariableConnection`](#pipelineschedulevariableconnection) | Pipeline schedule variables. (see [Connections](#connections)) |
### `PipelineSchedulePermissions`
@ -16895,6 +16950,18 @@ Represents pipeline counts for the project.
| <a id="pipelineschedulepermissionstakeownershippipelineschedule"></a>`takeOwnershipPipelineSchedule` | [`Boolean!`](#boolean) | Indicates the user can perform `take_ownership_pipeline_schedule` on this resource. |
| <a id="pipelineschedulepermissionsupdatepipelineschedule"></a>`updatePipelineSchedule` | [`Boolean!`](#boolean) | Indicates the user can perform `update_pipeline_schedule` on this resource. |
### `PipelineScheduleVariable`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="pipelineschedulevariableid"></a>`id` | [`ID!`](#id) | ID of the variable. |
| <a id="pipelineschedulevariablekey"></a>`key` | [`String`](#string) | Name of the variable. |
| <a id="pipelineschedulevariableraw"></a>`raw` | [`Boolean`](#boolean) | Indicates whether the variable is raw. |
| <a id="pipelineschedulevariablevalue"></a>`value` | [`String`](#string) | Value of the variable. |
| <a id="pipelineschedulevariablevariabletype"></a>`variableType` | [`CiVariableType`](#civariabletype) | Type of the variable. |
### `PipelineSecurityReportFinding`
Represents vulnerability finding of a security report on the pipeline.
@ -23803,6 +23870,7 @@ Implementations:
- [`CiInstanceVariable`](#ciinstancevariable)
- [`CiManualVariable`](#cimanualvariable)
- [`CiProjectVariable`](#ciprojectvariable)
- [`PipelineScheduleVariable`](#pipelineschedulevariable)
##### Fields
@ -24686,6 +24754,18 @@ The rotation user and color palette.
| <a id="oncalluserinputtypecolorweight"></a>`colorWeight` | [`DataVisualizationWeightEnum`](#datavisualizationweightenum) | Color weight to assign to for the on-call user. To view on-call schedules in GitLab, do not provide a value below 500. A value between 500 and 950 ensures sufficient contrast. |
| <a id="oncalluserinputtypeusername"></a>`username` | [`String!`](#string) | Username of the user to participate in the on-call rotation. For example, `"user_one"`. |
### `PipelineScheduleVariableInput`
Attributes for the pipeline schedule variable.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="pipelineschedulevariableinputkey"></a>`key` | [`String!`](#string) | Name of the variable. |
| <a id="pipelineschedulevariableinputvalue"></a>`value` | [`String!`](#string) | Value of the variable. |
| <a id="pipelineschedulevariableinputvariabletype"></a>`variableType` | [`CiVariableType!`](#civariabletype) | Type of the variable. |
### `ReleaseAssetLinkInput`
Fields that are available when modifying a release asset link.

View File

@ -867,13 +867,8 @@ Here's an example dependency scanning report:
### CycloneDX Software Bill of Materials
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/350509) in GitLab 14.8 in [Beta](../../../policy/alpha-beta-support.md#beta-features).
NOTE:
CycloneDX SBOMs are a [Beta](../../../policy/alpha-beta-support.md#beta-features) feature,
and the reports are subject to change during the beta period. Do not build integrations
that rely on the format of these SBOMs staying consistent, as the format might change
before the feature is made generally available.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/350509) in GitLab 14.8 in [Beta](../../../policy/alpha-beta-support.md#beta-features).
> - Generally available in GitLab 15.7.
In addition to the [JSON report file](#reports-json-format), the [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium)
Dependency Scanning tool outputs a [CycloneDX](https://cyclonedx.org/) Software Bill of Materials (SBOM) for

View File

@ -319,6 +319,18 @@ this command:
pip cache purge
```
### Multiple `index-url` or `extra-index-url` parameters
You can define multiple `index-url` and `extra-index-url` parameters.
If you use the same domain name (such as `gitlab.example.com`) multiple times with different authentication
tokens, `pip` may not be able to find your packages. This problem is due to how `pip`
[registers and stores your tokens](https://github.com/pypa/pip/pull/10904#issuecomment-1126690115) during commands executions.
To workaround this issue, you can use a [group deploy token](../../project/deploy_tokens/index.md) with the
scope `read_package_registry` from a common parent group for all projects or groups targeted by the
`index-url` and `extra-index-url` values.
## Supported CLI commands
The GitLab PyPI repository supports the following CLI commands:

View File

@ -15,6 +15,7 @@ module Gitlab
# end
class Client
include ::Gitlab::Utils::StrongMemoize
include ::Gitlab::GithubImport::Clients::SearchRepos
attr_reader :octokit
@ -182,19 +183,6 @@ module Gitlab
end
end
def search_repos_by_name(name, options = {})
with_retry { octokit.search_repositories(search_query(str: name, type: :name), options).to_h }
end
def search_query(str:, type:, include_collaborations: true, include_orgs: true)
query = "#{str} in:#{type} is:public,private user:#{octokit.user.to_h[:login]}"
query = [query, collaborations_subquery].join(' ') if include_collaborations
query = [query, organizations_subquery].join(' ') if include_orgs
query
end
# Returns `true` if we're still allowed to perform API calls.
# Search API has rate limit of 30, use lowered threshold when search is used.
def requests_remaining?

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Gitlab
module GithubImport
module Clients
class Proxy
attr_reader :client
def initialize(access_token, client_options)
@client = pick_client(access_token, client_options)
end
def repos(search_text, pagination_options)
return { repos: filtered(client.repos, search_text) } if use_legacy?
if use_graphql?
fetch_repos_via_graphql(search_text, pagination_options)
else
fetch_repos_via_rest(search_text, pagination_options)
end
end
private
def fetch_repos_via_rest(search_text, pagination_options)
{ repos: client.search_repos_by_name(search_text, pagination_options)[:items] }
end
def fetch_repos_via_graphql(search_text, pagination_options)
response = client.search_repos_by_name_graphql(search_text, pagination_options)
{
repos: response.dig(:data, :search, :nodes),
page_info: response.dig(:data, :search, :pageInfo)
}
end
def pick_client(access_token, client_options)
return Gitlab::GithubImport::Client.new(access_token) unless use_legacy?
Gitlab::LegacyGithubImport::Client.new(access_token, **client_options)
end
def filtered(collection, search_text)
return collection if search_text.blank?
collection.select { |item| item[:name].to_s.downcase.include?(search_text) }
end
def use_legacy?
Feature.disabled?(:remove_legacy_github_client)
end
def use_graphql?
Feature.enabled?(:github_client_fetch_repos_via_graphql)
end
end
end
end
end

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
module Gitlab
module GithubImport
module Clients
module SearchRepos
def search_repos_by_name_graphql(name, options = {})
with_retry do
octokit.post(
'/graphql',
{ query: graphql_search_repos_body(name, options) }.to_json
).to_h
end
end
def search_repos_by_name(name, options = {})
with_retry do
octokit.search_repositories(
search_repos_query(str: name, type: :name),
options
).to_h
end
end
private
def graphql_search_repos_body(name, options)
query = search_repos_query(str: name, type: :name)
query = "query: \"#{query}\""
first = options[:first].present? ? ", first: #{options[:first]}" : ''
after = options[:after].present? ? ", after: \"#{options[:after]}\"" : ''
<<-TEXT
{
search(type: REPOSITORY, #{query}#{first}#{after}) {
nodes {
__typename
... on Repository {
id: databaseId
name
full_name: nameWithOwner
owner { login }
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
TEXT
end
def search_repos_query(str:, type:, include_collaborations: true, include_orgs: true)
query = "#{str} in:#{type} is:public,private user:#{octokit.user.to_h[:login]}"
query = [query, collaborations_subquery].join(' ') if include_collaborations
query = [query, organizations_subquery].join(' ') if include_orgs
query
end
end
end
end
end

View File

@ -27609,9 +27609,6 @@ msgstr ""
msgid "No labels with such name or description"
msgstr ""
msgid "No license. All rights reserved"
msgstr ""
msgid "No matches found"
msgstr ""
@ -36875,6 +36872,15 @@ msgstr ""
msgid "SecurityOrchestration|%{branches} branch"
msgstr ""
msgid "SecurityOrchestration|%{scannerStart}%{scanner}%{scannerEnd}"
msgstr ""
msgid "SecurityOrchestration|%{scannerStart}%{scanner}%{scannerEnd} on runners with the %{tags} and %{lastTag} tags"
msgstr ""
msgid "SecurityOrchestration|%{scannerStart}%{scanner}%{scannerEnd} on runners with the %{tags} tag"
msgstr ""
msgid "SecurityOrchestration|%{scanners}"
msgstr ""

View File

@ -9,6 +9,9 @@ module QA
view 'app/assets/javascripts/clusters/forms/components/integration_form.vue' do
element :integration_status_toggle
element :base_domain_field
end
view 'app/assets/javascripts/integrations/edit/components/integration_form_actions.vue' do
element :save_changes_button
end

View File

@ -13,7 +13,7 @@ module QA
element :service_password_field, ':data-qa-selector="`${fieldId}_field`"' # rubocop:disable QA/ElementWithPattern
end
view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do
view 'app/assets/javascripts/integrations/edit/components/integration_form_actions.vue' do
element :save_changes_button
end

View File

@ -19,7 +19,7 @@ module QA
element :service_jira_issue_transition_id_field
end
view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do
view 'app/assets/javascripts/integrations/edit/components/integration_form_actions.vue' do
element :save_changes_button
end

View File

@ -9,6 +9,9 @@ module QA
view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do
element :recipients_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern
element :notify_only_broken_pipelines_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern
end
view 'app/assets/javascripts/integrations/edit/components/integration_form_actions.vue' do
element :save_changes_button
end

View File

@ -41,6 +41,16 @@ RSpec.describe Import::GithubController do
expect(response).to render_template(:new)
end
end
it 'gets authorization url using oauth client' do
allow(controller).to receive(:logged_in_with_provider?).and_return(true)
expect(controller).to receive(:go_to_provider_for_permissions).and_call_original
expect_next_instance_of(OAuth2::Client) do |client|
expect(client.auth_code).to receive(:authorize_url).and_call_original
end
get :new
end
end
describe "GET callback" do
@ -124,7 +134,48 @@ RSpec.describe Import::GithubController do
end
describe "GET status" do
context 'when using OAuth' do
shared_examples 'calls repos through Clients::Proxy with expected args' do
it 'calls repos list from provider with expected args' do
expect_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |client|
expect(client).to receive(:repos)
.with(expected_filter, expected_pagination_options)
.and_return({ repos: [], page_info: {} })
end
get :status, params: params, format: :json
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['imported_projects'].size).to eq 0
expect(json_response['provider_repos'].size).to eq 0
expect(json_response['incompatible_repos'].size).to eq 0
expect(json_response['page_info']).to eq({})
end
end
let(:provider_token) { 'asdasd12345' }
let(:client_auth_success) { true }
let(:client_stub) { instance_double(Gitlab::GithubImport::Client, user: { login: 'user' }) }
let(:expected_pagination_options) { pagination_params.merge(first: 25, page: 1, per_page: 25) }
let(:expected_filter) { nil }
let(:params) { nil }
let(:pagination_params) { { before: nil, after: nil } }
let(:provider_repos) { [] }
before do
allow_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |proxy|
if client_auth_success
allow(proxy).to receive(:repos).and_return({ repos: provider_repos })
allow(proxy).to receive(:client).and_return(client_stub)
else
allow(proxy).to receive(:repos).and_raise(Octokit::Unauthorized)
end
end
session[:"#{provider}_access_token"] = provider_token
end
context 'with OAuth' do
let(:provider_token) { nil }
before do
allow(controller).to receive(:logged_in_with_provider?).and_return(true)
end
@ -146,178 +197,133 @@ RSpec.describe Import::GithubController do
end
end
context 'when feature remove_legacy_github_client is disabled' do
before do
stub_feature_flags(remove_legacy_github_client: false)
session[:"#{provider}_access_token"] = 'asdasd12345'
end
context 'with invalid access token' do
let(:client_auth_success) { false }
it_behaves_like 'a GitHub-ish import controller: GET status'
it "handles an invalid token" do
get :status, format: :json
it 'uses Gitlab::LegacyGitHubImport::Client' do
expect(controller.send(:client)).to be_instance_of(Gitlab::LegacyGithubImport::Client)
end
it 'fetches repos using legacy client' do
expect_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client|
expect(client).to receive(:repos).and_return([])
end
get :status
end
it 'gets authorization url using legacy client' do
allow(controller).to receive(:logged_in_with_provider?).and_return(true)
expect(controller).to receive(:go_to_provider_for_permissions).and_call_original
expect_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client|
expect(client).to receive(:authorize_url).and_call_original
end
get :new
expect(session[:"#{provider}_access_token"]).to be_nil
expect(controller).to redirect_to(new_import_url)
expect(flash[:alert]).to eq("Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account.")
end
end
context 'when feature remove_legacy_github_client is enabled' do
context 'when user has few different repos' do
let(:repo_struct) { Struct.new(:id, :login, :full_name, :name, :owner, keyword_init: true) }
let(:provider_repos) do
[repo_struct.new(login: 'vim', full_name: 'asd/vim', name: 'vim', owner: { login: 'owner' })]
end
let!(:imported_project) do
create(
:project,
import_type: provider, namespace: user.namespace,
import_status: :finished, import_source: 'example/repo'
)
end
it 'responds with expected high-level structure' do
get :status, format: :json
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.dig("imported_projects", 0, "id")).to eq(imported_project.id)
expect(json_response.dig("provider_repos", 0, "id")).to eq(provider_repos[0].id)
end
end
it_behaves_like 'calls repos through Clients::Proxy with expected args'
context 'with namespace_id param' do
let_it_be(:user) { create(:user) }
before do
stub_feature_flags(remove_legacy_github_client: true)
session[:"#{provider}_access_token"] = 'asdasd12345'
sign_in(user)
end
it_behaves_like 'a GitHub-ish import controller: GET status'
it 'uses Gitlab::GithubImport::Client' do
expect(controller.send(:client)).to be_instance_of(Gitlab::GithubImport::Client)
after do
sign_out(user)
end
it 'fetches repos using latest github client' do
expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
expect(client).to receive(:repos).and_return([])
end
context 'when user is allowed to create projects in this namespace' do
let(:namespace) { create(:namespace, owner: user) }
get :status
end
it 'provides namespace to the template' do
get :status, params: { namespace_id: namespace.id }, format: :html
it 'gets authorization url using oauth client' do
allow(controller).to receive(:logged_in_with_provider?).and_return(true)
expect(controller).to receive(:go_to_provider_for_permissions).and_call_original
expect_next_instance_of(OAuth2::Client) do |client|
expect(client.auth_code).to receive(:authorize_url).and_call_original
end
get :new
end
context 'pagination' do
context 'when no page is specified' do
it 'requests first page' do
expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
expect(client).to receive(:repos).with({ page: 1, per_page: 25 }).and_return([])
end
get :status
end
end
context 'when page is specified' do
it 'requests repos with specified page' do
expect_next_instance_of(Octokit::Client) do |client|
expect(client).to receive(:repos).with(nil, { page: 2, per_page: 25 }).and_return([].to_enum)
end
get :status, params: { page: 2 }
end
expect(response).to have_gitlab_http_status :ok
expect(assigns(:namespace)).to eq(namespace)
end
end
context 'when filtering' do
let(:filter) { 'test' }
let(:user_login) { 'user' }
let(:collaborations_subquery) { 'repo:repo1 repo:repo2' }
let(:organizations_subquery) { 'org:org1 org:org2' }
let(:search_query) { "test in:name is:public,private user:#{user_login} #{collaborations_subquery} #{organizations_subquery}" }
context 'when user is not allowed to create projects in this namespace' do
let(:namespace) { create(:namespace) }
before do
allow_next_instance_of(Octokit::Client) do |client|
allow(client).to receive(:user).and_return(double(login: user_login))
end
it 'renders 404' do
get :status, params: { namespace_id: namespace.id }, format: :html
expect(response).to have_gitlab_http_status :not_found
end
end
end
context 'pagination' do
context 'when cursor is specified' do
let(:pagination_params) { { before: nil, after: 'CURSOR' } }
let(:params) { pagination_params }
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
context 'when page is specified' do
let(:pagination_params) { { before: nil, after: nil, page: 2 } }
let(:expected_pagination_options) { pagination_params.merge(first: 25, page: 2, per_page: 25) }
let(:params) { pagination_params }
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
end
context 'when filtering' do
let(:filter_param) { FFaker::Lorem.word }
let(:params) { { filter: filter_param } }
let(:expected_filter) { filter_param }
it_behaves_like 'calls repos through Clients::Proxy with expected args'
context 'with pagination' do
context 'when before cursor present' do
let(:pagination_params) { { before: 'before-cursor', after: nil } }
let(:params) { { filter: filter_param }.merge(pagination_params) }
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
it 'makes request to github search api' do
expect_next_instance_of(Octokit::Client) do |client|
expect(client).to receive(:user).and_return({ login: user_login })
expect(client).to receive(:search_repositories).with(search_query, { page: 1, per_page: 25 }).and_return({ items: [].to_enum })
end
context 'when after cursor present' do
let(:pagination_params) { { before: nil, after: 'after-cursor' } }
let(:params) { { filter: filter_param }.merge(pagination_params) }
expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
expect(client).to receive(:collaborations_subquery).and_return(collaborations_subquery)
expect(client).to receive(:organizations_subquery).and_return(organizations_subquery)
end
get :status, params: { filter: filter }, format: :json
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
end
context 'pagination' do
context 'when no page is specified' do
it 'requests first page' do
expect_next_instance_of(Octokit::Client) do |client|
expect(client).to receive(:user).and_return({ login: user_login })
expect(client).to receive(:search_repositories).with(search_query, { page: 1, per_page: 25 }).and_return({ items: [].to_enum })
end
context 'when user input contains colons and spaces' do
let(:filter_param) { ' test1:test2 test3 : test4 ' }
let(:expected_filter) { 'test1test2test3test4' }
expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
expect(client).to receive(:collaborations_subquery).and_return(collaborations_subquery)
expect(client).to receive(:organizations_subquery).and_return(organizations_subquery)
end
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
end
get :status, params: { filter: filter }, format: :json
end
end
context 'when rate limit threshold is exceeded' do
before do
allow(controller).to receive(:status).and_raise(Gitlab::GithubImport::RateLimitError)
end
context 'when page is specified' do
it 'requests repos with specified page' do
expect_next_instance_of(Octokit::Client) do |client|
expect(client).to receive(:user).and_return({ login: user_login })
expect(client).to receive(:search_repositories).with(search_query, { page: 2, per_page: 25 }).and_return({ items: [].to_enum })
end
it 'returns 429' do
get :status, format: :json
expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
expect(client).to receive(:collaborations_subquery).and_return(collaborations_subquery)
expect(client).to receive(:organizations_subquery).and_return(organizations_subquery)
end
get :status, params: { filter: filter, page: 2 }, format: :json
end
end
end
context 'when user input contains colons and spaces' do
before do
allow_next_instance_of(Gitlab::GithubImport::Client) do |client|
allow(client).to receive(:search_repos_by_name).and_return(items: [])
end
end
it 'sanitizes user input' do
filter = ' test1:test2 test3 : test4 '
expected_filter = 'test1test2test3test4'
get :status, params: { filter: filter }, format: :json
expect(assigns(:filter)).to eq(expected_filter)
end
end
context 'when rate limit threshold is exceeded' do
before do
allow(controller).to receive(:status).and_raise(Gitlab::GithubImport::RateLimitError)
end
it 'returns 429' do
get :status, params: { filter: 'test' }, format: :json
expect(response).to have_gitlab_http_status(:too_many_requests)
end
end
expect(response).to have_gitlab_http_status(:too_many_requests)
end
end
end

View File

@ -208,35 +208,52 @@ describe('ImportProjectsTable', () => {
});
describe('when paginatable is set to true', () => {
const pageInfo = { page: 1 };
const initState = {
namespaces: [{ fullPath: 'path' }],
pageInfo: { page: 1, hasNextPage: true },
repositories: [
{ importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
],
};
beforeEach(() => {
createComponent({
state: {
namespaces: [{ fullPath: 'path' }],
pageInfo,
repositories: [
{ importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
],
},
paginatable: true,
describe('with hasNextPage true', () => {
beforeEach(() => {
createComponent({
state: initState,
paginatable: true,
});
});
it('does not call fetchRepos on mount', () => {
expect(fetchReposFn).not.toHaveBeenCalled();
});
it('renders intersection observer component', () => {
expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true);
});
it('calls fetchRepos when intersection observer appears', async () => {
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(fetchReposFn).toHaveBeenCalled();
});
});
it('does not call fetchRepos on mount', () => {
expect(fetchReposFn).not.toHaveBeenCalled();
});
describe('with hasNextPage false', () => {
beforeEach(() => {
initState.pageInfo.hasNextPage = false;
it('renders intersection observer component', () => {
expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true);
});
createComponent({
state: initState,
paginatable: true,
});
});
it('calls fetchRepos when intersection observer appears', async () => {
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(fetchReposFn).toHaveBeenCalled();
it('does not render intersection observer component', () => {
expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(false);
});
});
});

View File

@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import { createAlert } from '~/flash';
import { STATUSES } from '~/import_entities/constants';
import { STATUSES, PROVIDERS } from '~/import_entities/constants';
import actionsFactory from '~/import_entities/import_projects/store/actions';
import { getImportTarget } from '~/import_entities/import_projects/store/getters';
import {
@ -15,6 +15,7 @@ import {
RECEIVE_JOBS_SUCCESS,
SET_PAGE,
SET_FILTER,
SET_PAGE_CURSORS,
} from '~/import_entities/import_projects/store/mutation_types';
import state from '~/import_entities/import_projects/store/state';
import axios from '~/lib/utils/axios_utils';
@ -72,7 +73,11 @@ describe('import_projects store actions', () => {
describe('fetchRepos', () => {
let mock;
const payload = { imported_projects: [{}], provider_repos: [{}] };
const payload = {
imported_projects: [{}],
provider_repos: [{}],
page_info: { startCursor: 'start', endCursor: 'end', hasNextPage: true },
};
beforeEach(() => {
mock = new MockAdapter(axios);
@ -80,23 +85,53 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
describe('with a successful request', () => {
it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction(
fetchRepos,
null,
localState,
[
{ type: REQUEST_REPOS },
{ type: SET_PAGE, payload: 1 },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
[],
);
return testAction(
fetchRepos,
null,
localState,
[
{ type: REQUEST_REPOS },
{ type: SET_PAGE, payload: 1 },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
[],
);
});
describe('when provider is GITHUB_PROVIDER', () => {
beforeEach(() => {
localState.provider = PROVIDERS.GITHUB;
});
it('commits SET_PAGE_CURSORS instead of SET_PAGE', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction(
fetchRepos,
null,
localState,
[
{ type: REQUEST_REPOS },
{
type: SET_PAGE_CURSORS,
payload: { startCursor: 'start', endCursor: 'end', hasNextPage: true },
},
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
],
[],
);
});
});
});
it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
@ -111,18 +146,52 @@ describe('import_projects store actions', () => {
);
});
it('includes page in url query params', async () => {
let requestedUrl;
mock.onGet().reply((config) => {
requestedUrl = config.url;
return [200, payload];
describe('with pagination params', () => {
it('includes page in url query params', async () => {
let requestedUrl;
mock.onGet().reply((config) => {
requestedUrl = config.url;
return [200, payload];
});
const localStateWithPage = { ...localState, pageInfo: { page: 2 } };
await testAction(
fetchRepos,
null,
localStateWithPage,
expect.any(Array),
expect.any(Array),
);
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
});
const localStateWithPage = { ...localState, pageInfo: { page: 2 } };
describe('when provider is "github"', () => {
beforeEach(() => {
localState.provider = PROVIDERS.GITHUB;
});
await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array));
it('includes cursor in url query params', async () => {
let requestedUrl;
mock.onGet().reply((config) => {
requestedUrl = config.url;
return [200, payload];
});
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
const localStateWithPage = { ...localState, pageInfo: { endCursor: 'endTest' } };
await testAction(
fetchRepos,
null,
localStateWithPage,
expect.any(Array),
expect.any(Array),
);
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?after=endTest`);
});
});
});
it('correctly keeps current page on an unsuccessful request', () => {

View File

@ -308,4 +308,14 @@ describe('import_projects store mutations', () => {
expect(state.pageInfo.page).toBe(NEW_PAGE);
});
});
describe(`${types.SET_PAGE_CURSORS}`, () => {
it('sets page cursors', () => {
const NEW_CURSORS = { startCursor: 'startCur', endCursor: 'endCur', hasNextPage: false };
state = { pageInfo: { page: 1, startCursor: null, endCursor: null, hasNextPage: true } };
mutations[types.SET_PAGE_CURSORS](state, NEW_CURSORS);
expect(state.pageInfo).toEqual({ ...NEW_CURSORS, page: 1 });
});
});
});

View File

@ -0,0 +1,227 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import IntegrationFormActions from '~/integrations/edit/components/integration_form_actions.vue';
import { integrationLevels } from '~/integrations/constants';
import { createStore } from '~/integrations/edit/store';
import { mockIntegrationProps } from '../mock_data';
describe('IntegrationFormActions', () => {
let wrapper;
const createComponent = ({ customStateProps = {} } = {}) => {
const store = createStore({
customState: { ...mockIntegrationProps, ...customStateProps },
});
jest.spyOn(store, 'dispatch');
wrapper = shallowMountExtended(IntegrationFormActions, {
store,
propsData: {
hasSections: false,
},
});
};
const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal);
const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal);
const findResetButton = () => wrapper.findByTestId('reset-button');
const findSaveButton = () => wrapper.findByTestId('save-button');
const findTestButton = () => wrapper.findByTestId('test-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
describe('ConfirmationModal', () => {
it.each`
desc | integrationLevel | shouldRender
${'Should'} | ${integrationLevels.INSTANCE} | ${true}
${'Should'} | ${integrationLevels.GROUP} | ${true}
${'Should not'} | ${integrationLevels.PROJECT} | ${false}
`(
'$desc render the ConfirmationModal when integrationLevel is "$integrationLevel"',
({ integrationLevel, shouldRender }) => {
createComponent({
customStateProps: {
integrationLevel,
},
});
expect(findConfirmationModal().exists()).toBe(shouldRender);
},
);
});
describe('ResetConfirmationModal', () => {
it.each`
desc | integrationLevel | resetPath | shouldRender
${'Should not'} | ${integrationLevels.INSTANCE} | ${''} | ${false}
${'Should not'} | ${integrationLevels.GROUP} | ${''} | ${false}
${'Should not'} | ${integrationLevels.PROJECT} | ${''} | ${false}
${'Should'} | ${integrationLevels.INSTANCE} | ${'resetPath'} | ${true}
${'Should'} | ${integrationLevels.GROUP} | ${'resetPath'} | ${true}
${'Should not'} | ${integrationLevels.PROJECT} | ${'resetPath'} | ${false}
`(
'$desc render the ResetConfirmationModal modal when integrationLevel="$integrationLevel" and resetPath="$resetPath"',
({ integrationLevel, resetPath, shouldRender }) => {
createComponent({
customStateProps: {
integrationLevel,
resetPath,
},
});
expect(findResetConfirmationModal().exists()).toBe(shouldRender);
},
);
});
describe('Buttons rendering', () => {
it.each`
integrationLevel | canTest | resetPath | saveBtn | testBtn | cancelBtn | resetBtn
${integrationLevels.PROJECT} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${false}
${integrationLevels.PROJECT} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${false}
${integrationLevels.PROJECT} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false}
${integrationLevels.GROUP} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${true}
${integrationLevels.GROUP} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${true}
${integrationLevels.GROUP} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false}
${integrationLevels.INSTANCE} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${true}
${integrationLevels.INSTANCE} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${true}
${integrationLevels.INSTANCE} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false}
`(
'on $integrationLevel when canTest="$canTest" and resetPath="$resetPath"',
({ integrationLevel, canTest, resetPath, saveBtn, testBtn, cancelBtn, resetBtn }) => {
createComponent({
customStateProps: {
integrationLevel,
canTest,
resetPath,
},
});
expect(findSaveButton().exists()).toBe(saveBtn);
expect(findTestButton().exists()).toBe(testBtn);
expect(findCancelButton().exists()).toBe(cancelBtn);
expect(findResetButton().exists()).toBe(resetBtn);
},
);
});
describe('interactions', () => {
describe('Save button clicked', () => {
const createAndSave = (integrationLevel, withModal = false) => {
createComponent({
customStateProps: {
integrationLevel,
canTest: true,
resetPath: 'resetPath',
},
});
findSaveButton().vm.$emit('click', new Event('click'));
if (withModal) {
findConfirmationModal().vm.$emit('submit');
}
wrapper.setProps({
isSaving: true,
});
};
const sharedFormStateTest = async (integrationLevel, withModal = false) => {
createAndSave(integrationLevel, withModal);
await nextTick();
const saveBtnWrapper = findSaveButton();
const testBtnWrapper = findTestButton();
const cancelBtnWrapper = findCancelButton();
expect(saveBtnWrapper.props('loading')).toBe(true);
expect(saveBtnWrapper.props('disabled')).toBe(true);
expect(testBtnWrapper.props('loading')).toBe(false);
expect(testBtnWrapper.props('disabled')).toBe(true);
expect(cancelBtnWrapper.props('loading')).toBe(false);
expect(cancelBtnWrapper.props('disabled')).toBe(true);
};
describe('on "project" level', () => {
const integrationLevel = integrationLevels.PROJECT;
it('emits the "save" event right away', async () => {
createAndSave(integrationLevel);
await nextTick();
expect(wrapper.emitted('save')).toHaveLength(1);
});
it('toggles the state of other buttons', async () => {
await sharedFormStateTest(integrationLevel);
const resetBtnWrapper = findResetButton();
expect(resetBtnWrapper.exists()).toBe(false);
});
});
describe.each([integrationLevels.INSTANCE, integrationLevels.GROUP])(
'on "%s" level',
(integrationLevel) => {
it('emits the "save" event only after the confirmation', () => {
createComponent({
customStateProps: {
integrationLevel,
},
});
findSaveButton().vm.$emit('click', new Event('click'));
expect(wrapper.emitted('save')).toBeUndefined();
findConfirmationModal().vm.$emit('submit');
expect(wrapper.emitted('save')).toHaveLength(1);
});
it('toggles the state of other buttons', async () => {
await sharedFormStateTest(integrationLevel, true);
const resetBtnWrapper = findResetButton();
expect(resetBtnWrapper.props('loading')).toBe(false);
expect(resetBtnWrapper.props('disabled')).toBe(true);
});
},
);
});
describe('Reset button clicked', () => {
describe.each([integrationLevels.INSTANCE, integrationLevels.GROUP])(
'on "%s" level',
(integrationLevel) => {
it('emits the "reset" event only after the confirmation', () => {
createComponent({
customStateProps: {
integrationLevel,
resetPath: 'resetPath',
},
});
findResetButton().vm.$emit('click', new Event('click'));
expect(wrapper.emitted('reset')).toBeUndefined();
findResetConfirmationModal().vm.$emit('reset');
expect(wrapper.emitted('reset')).toHaveLength(1);
});
},
);
});
describe('Test button clicked', () => {
it('emits the "test" event when clicked', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.PROJECT,
canTest: true,
},
});
findTestButton().vm.$emit('click', new Event('click'));
expect(wrapper.emitted('test')).toHaveLength(1);
});
});
});
});

View File

@ -1,21 +1,20 @@
import { GlAlert, GlBadge, GlForm } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import * as Sentry from '@sentry/browser';
import { setHTMLFixture } from 'helpers/fixtures';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
import IntegrationFormActions from '~/integrations/edit/components/integration_form_actions.vue';
import {
integrationLevels,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
INTEGRATION_FORM_TYPE_SLACK,
@ -60,7 +59,6 @@ describe('IntegrationForm', () => {
stubs: {
OverrideDropdown,
ActiveCheckbox,
ConfirmationModal,
TriggerFields,
},
mocks: {
@ -73,12 +71,6 @@ describe('IntegrationForm', () => {
const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown);
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal);
const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal);
const findResetButton = () => wrapper.findByTestId('reset-button');
const findProjectSaveButton = () => wrapper.findByTestId('save-button');
const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group');
const findTestButton = () => wrapper.findByTestId('test-button');
const findTriggerFields = () => wrapper.findComponent(TriggerFields);
const findAlert = () => wrapper.findComponent(GlAlert);
const findGlBadge = () => wrapper.findComponent(GlBadge);
@ -91,6 +83,7 @@ describe('IntegrationForm', () => {
const findConnectionSectionComponent = () =>
findConnectionSection().findComponent(IntegrationSectionConnection);
const findHelpHtml = () => wrapper.findByTestId('help-html');
const findFormActions = () => wrapper.findComponent(IntegrationFormActions);
beforeEach(() => {
mockAxios = new MockAdapter(axios);
@ -102,108 +95,6 @@ describe('IntegrationForm', () => {
});
describe('template', () => {
describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.INSTANCE,
},
});
expect(findConfirmationModal().exists()).toBe(true);
});
describe('resetPath is empty', () => {
it('does not render ResetConfirmationModal and button', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.INSTANCE,
},
});
expect(findResetButton().exists()).toBe(false);
expect(findResetConfirmationModal().exists()).toBe(false);
});
});
describe('resetPath is present', () => {
it('renders ResetConfirmationModal and button', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.INSTANCE,
resetPath: 'resetPath',
},
});
expect(findResetButton().exists()).toBe(true);
expect(findResetConfirmationModal().exists()).toBe(true);
});
});
});
describe('integrationLevel is group', () => {
it('renders ConfirmationModal', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
},
});
expect(findConfirmationModal().exists()).toBe(true);
});
describe('resetPath is empty', () => {
it('does not render ResetConfirmationModal and button', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
},
});
expect(findResetButton().exists()).toBe(false);
expect(findResetConfirmationModal().exists()).toBe(false);
});
});
describe('resetPath is present', () => {
it('renders ResetConfirmationModal and button', () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
resetPath: 'resetPath',
},
});
expect(findResetButton().exists()).toBe(true);
expect(findResetConfirmationModal().exists()).toBe(true);
});
});
});
describe('integrationLevel is project', () => {
it('does not render ConfirmationModal', () => {
createComponent({
customStateProps: {
integrationLevel: 'project',
},
});
expect(findConfirmationModal().exists()).toBe(false);
});
it('does not render ResetConfirmationModal and button', () => {
createComponent({
customStateProps: {
integrationLevel: 'project',
resetPath: 'resetPath',
},
});
expect(findResetButton().exists()).toBe(false);
expect(findResetConfirmationModal().exists()).toBe(false);
});
});
describe('triggerEvents is present', () => {
it('renders TriggerFields', () => {
const events = [{ title: 'push' }];
@ -462,110 +353,84 @@ describe('IntegrationForm', () => {
);
});
describe('when `save` button is clicked', () => {
describe('buttons', () => {
beforeEach(async () => {
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: true,
},
mountFn: mountExtended,
});
await findProjectSaveButton().vm.$emit('click', new Event('click'));
describe('Response to the "save" event (form submission)', () => {
const prepareComponentAndSave = async (initialActivated = true, checkValidityReturn) => {
createComponent({
customStateProps: {
showActive: true,
initialActivated,
fields: [mockField],
},
mountFn: mountExtended,
});
jest.spyOn(findGlForm().element, 'submit');
jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(checkValidityReturn);
it('sets save button `loading` prop to `true`', () => {
expect(findProjectSaveButton().props('loading')).toBe(true);
});
findFormActions().vm.$emit('save');
await nextTick();
};
it('sets test button `disabled` prop to `true`', () => {
expect(findTestButton().props('disabled')).toBe(true);
});
});
describe.each`
checkValidityReturn | integrationActive
${true} | ${false}
${true} | ${true}
${false} | ${false}
it.each`
desc | checkValidityReturn | integrationActive | shouldSubmit
${'form is valid'} | ${true} | ${false} | ${true}
${'form is valid'} | ${true} | ${true} | ${true}
${'form is invalid'} | ${false} | ${false} | ${true}
${'form is invalid'} | ${false} | ${true} | ${false}
`(
'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)',
({ integrationActive, checkValidityReturn }) => {
beforeEach(async () => {
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: integrationActive,
},
mountFn: mountExtended,
});
jest.spyOn(findGlForm().element, 'submit');
jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(checkValidityReturn);
'when $desc (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)',
async ({ integrationActive, checkValidityReturn, shouldSubmit }) => {
await prepareComponentAndSave(integrationActive, checkValidityReturn);
await findProjectSaveButton().vm.$emit('click', new Event('click'));
});
it('submit form', () => {
if (shouldSubmit) {
expect(findGlForm().element.submit).toHaveBeenCalledTimes(1);
});
} else {
expect(findGlForm().element.submit).not.toHaveBeenCalled();
}
},
);
describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => {
it('flips `isSaving` to `true`', async () => {
await prepareComponentAndSave(true, true);
expect(findFormActions().props('isSaving')).toBe(true);
});
describe('when form is invalid', () => {
beforeEach(async () => {
await prepareComponentAndSave(true, false);
});
it('when form is invalid, it sets `isValidated` props on form fields', () => {
expect(findDynamicField().props('isValidated')).toBe(true);
});
it('resets `isSaving`', () => {
expect(findFormActions().props('isSaving')).toBe(false);
});
});
});
describe('Response to the "test" event from the actions', () => {
describe('when form is invalid', () => {
beforeEach(async () => {
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: true,
fields: [mockField],
},
mountFn: mountExtended,
});
jest.spyOn(findGlForm().element, 'submit');
jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(false);
await findProjectSaveButton().vm.$emit('click', new Event('click'));
});
it('does not submit form', () => {
expect(findGlForm().element.submit).not.toHaveBeenCalled();
});
it('sets save button `loading` prop to `false`', () => {
expect(findProjectSaveButton().props('loading')).toBe(false);
});
it('sets test button `disabled` prop to `false`', () => {
expect(findTestButton().props('disabled')).toBe(false);
findFormActions().vm.$emit('test');
await nextTick();
});
it('sets `isValidated` props on form fields', () => {
expect(findDynamicField().props('isValidated')).toBe(true);
});
});
});
describe('when `test` button is clicked', () => {
describe('when form is invalid', () => {
it('sets `isValidated` props on form fields', async () => {
createComponent({
customStateProps: {
showActive: true,
canTest: true,
fields: [mockField],
},
mountFn: mountExtended,
});
jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(false);
await findTestButton().vm.$emit('click', new Event('click'));
expect(findDynamicField().props('isValidated')).toBe(true);
it('resets `isTesting`', () => {
expect(findFormActions().props('isTesting')).toBe(false);
});
});
@ -576,26 +441,18 @@ describe('IntegrationForm', () => {
createComponent({
customStateProps: {
showActive: true,
canTest: true,
testPath: mockTestPath,
},
mountFn: mountExtended,
});
jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(true);
});
describe('buttons', () => {
beforeEach(async () => {
await findTestButton().vm.$emit('click', new Event('click'));
});
it('sets test button `loading` prop to `true`', () => {
expect(findTestButton().props('loading')).toBe(true);
});
it('sets save button `disabled` prop to `true`', () => {
expect(findProjectSaveButton().props('disabled')).toBe(true);
});
it('flips `isTesting` to `true`', async () => {
findFormActions().vm.$emit('test');
await nextTick();
expect(findFormActions().props('isTesting')).toBe(true);
});
describe.each`
@ -614,7 +471,7 @@ describe('IntegrationForm', () => {
service_response: serviceResponse,
});
await findTestButton().vm.$emit('click', new Event('click'));
findFormActions().vm.$emit('test');
await waitForPromises();
});
@ -622,14 +479,6 @@ describe('IntegrationForm', () => {
expect(mockToastShow).toHaveBeenCalledWith(expectToast);
});
it('sets `loading` prop of test button to `false`', () => {
expect(findTestButton().props('loading')).toBe(false);
});
it('sets save button `disabled` prop to `false`', () => {
expect(findProjectSaveButton().props('disabled')).toBe(false);
});
it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => {
expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0);
});
@ -638,44 +487,27 @@ describe('IntegrationForm', () => {
});
});
describe('when `reset-confirmation-modal` emits `reset` event', () => {
describe('Response to the "reset" event from the actions', () => {
const mockResetPath = '/reset';
describe('buttons', () => {
beforeEach(async () => {
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
canTest: true,
resetPath: mockResetPath,
},
});
await findResetConfirmationModal().vm.$emit('reset');
beforeEach(async () => {
mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
createComponent({
customStateProps: {
resetPath: mockResetPath,
},
});
it('sets reset button `loading` prop to `true`', () => {
expect(findResetButton().props('loading')).toBe(true);
});
findFormActions().vm.$emit('reset');
await nextTick();
});
it('sets other button `disabled` props to `true`', () => {
expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(true);
expect(findTestButton().props('disabled')).toBe(true);
});
it('flips `isResetting` to `true`', () => {
expect(findFormActions().props('isResetting')).toBe(true);
});
describe('when "reset settings" request fails', () => {
beforeEach(async () => {
mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
canTest: true,
resetPath: mockResetPath,
},
});
await findResetConfirmationModal().vm.$emit('reset');
await waitForPromises();
});
@ -687,13 +519,8 @@ describe('IntegrationForm', () => {
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
});
it('sets reset button `loading` prop to `false`', () => {
expect(findResetButton().props('loading')).toBe(false);
});
it('sets button `disabled` props to `false`', () => {
expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(false);
expect(findTestButton().props('disabled')).toBe(false);
it('resets `isResetting`', () => {
expect(findFormActions().props('isResetting')).toBe(false);
});
});
@ -702,96 +529,99 @@ describe('IntegrationForm', () => {
mockAxios.onPost(mockResetPath).replyOnce(httpStatus.OK);
createComponent({
customStateProps: {
integrationLevel: integrationLevels.GROUP,
resetPath: mockResetPath,
},
});
await findResetConfirmationModal().vm.$emit('reset');
findFormActions().vm.$emit('reset');
await waitForPromises();
});
it('calls `refreshCurrentPage`', () => {
expect(refreshCurrentPage).toHaveBeenCalledTimes(1);
});
});
describe('Slack integration', () => {
describe('Help and sections rendering', () => {
const dummyHelp = 'Foo Help';
it.each`
integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
`(
'$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration',
({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
createComponent({
provide: {
helpHtml,
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
sections,
type: integration,
},
});
expect(findAllSections().length > 0).toEqual(shouldShowSections);
expect(findHelpHtml().exists()).toBe(shouldShowHelp);
if (shouldShowHelp) {
expect(findHelpHtml().html()).toContain(helpHtml);
}
},
);
});
describe.each`
hasSections | hasFieldsWithoutSections | description
${true} | ${true} | ${'When having both: the sections and the fields without a section'}
${true} | ${false} | ${'When having the sections only'}
${false} | ${true} | ${'When having only the fields without a section'}
`('$description', ({ hasSections, hasFieldsWithoutSections }) => {
it.each`
prefix | integration | shouldUpgradeSlack | flagIsOn | shouldShowAlert
${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true} | ${true}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${true} | ${false}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${false} | ${false}
${'does not'} | ${'foo'} | ${true} | ${true} | ${false}
${'does not'} | ${'foo'} | ${false} | ${true} | ${false}
${'does not'} | ${'foo'} | ${true} | ${false} | ${false}
`(
'$prefix render the upgrade warning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
({ integration, shouldUpgradeSlack, flagIsOn, shouldShowAlert }) => {
createComponent({
provide: {
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
shouldUpgradeSlack,
type: integration,
sections: hasSections ? [mockSectionConnection] : [],
fields: hasFieldsWithoutSections ? [mockField] : [],
},
});
expect(findAlert().exists()).toBe(shouldShowAlert);
},
);
it('resets `isResetting`', async () => {
expect(findFormActions().props('isResetting')).toBe(false);
});
});
});
describe('Slack integration', () => {
describe('Help and sections rendering', () => {
const dummyHelp = 'Foo Help';
it.each`
integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
`(
'$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration',
({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
createComponent({
provide: {
helpHtml,
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
sections,
type: integration,
},
});
expect(findAllSections().length > 0).toEqual(shouldShowSections);
expect(findHelpHtml().exists()).toBe(shouldShowHelp);
if (shouldShowHelp) {
expect(findHelpHtml().html()).toContain(helpHtml);
}
},
);
});
describe.each`
hasSections | hasFieldsWithoutSections | description
${true} | ${true} | ${'When having both: the sections and the fields without a section'}
${true} | ${false} | ${'When having the sections only'}
${false} | ${true} | ${'When having only the fields without a section'}
`('$description', ({ hasSections, hasFieldsWithoutSections }) => {
it.each`
prefix | integration | shouldUpgradeSlack | flagIsOn | shouldShowAlert
${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true} | ${true}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${true} | ${false}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${false} | ${false}
${'does not'} | ${'foo'} | ${true} | ${true} | ${false}
${'does not'} | ${'foo'} | ${false} | ${true} | ${false}
${'does not'} | ${'foo'} | ${true} | ${false} | ${false}
`(
'$prefix render the upgrade warning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
({ integration, shouldUpgradeSlack, flagIsOn, shouldShowAlert }) => {
createComponent({
provide: {
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
shouldUpgradeSlack,
type: integration,
sections: hasSections ? [mockSectionConnection] : [],
fields: hasFieldsWithoutSections ? [mockField] : [],
},
});
expect(findAlert().exists()).toBe(shouldShowAlert);
},
);
});
});
});

View File

@ -14,6 +14,7 @@ RSpec.describe Types::Ci::PipelineScheduleType do
description
owner
active
project
lastPipeline
refForDisplay
refPath
@ -24,6 +25,12 @@ RSpec.describe Types::Ci::PipelineScheduleType do
cronTimezone
userPermissions
editPath
cron
cronTimezone
ref
variables
createdAt
updatedAt
]
expect(described_class).to have_graphql_fields(*expected_fields)

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::PipelineScheduleVariableType do
specify { expect(described_class.graphql_name).to eq('PipelineScheduleVariable') }
specify { expect(described_class.interfaces).to contain_exactly(Types::Ci::VariableInterface) }
specify { expect(described_class).to require_graphql_authorizations(:read_pipeline_schedule_variables) }
it 'contains attributes related to a pipeline message' do
expected_fields = %w[
id key raw value variable_type
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end

View File

@ -579,15 +579,113 @@ RSpec.describe Gitlab::GithubImport::Client do
allow(client.octokit).to receive(:user).and_return(user)
end
describe '#search_repos_by_name' do
it 'searches for repositories based on name' do
expected_search_query = 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2'
describe '#search_repos_by_name_graphql' do
let(:expected_query) { 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2' }
let(:expected_graphql_params) { "type: REPOSITORY, query: \"#{expected_query}\"" }
let(:expected_graphql) do
<<-TEXT
{
search(#{expected_graphql_params}) {
nodes {
__typename
... on Repository {
id: databaseId
name
full_name: nameWithOwner
owner { login }
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
TEXT
end
expect(client.octokit).to receive(:search_repositories).with(expected_search_query, {})
it 'searches for repositories based on name' do
expect(client.octokit).to receive(:post).with(
'/graphql', { query: expected_graphql }.to_json
)
client.search_repos_by_name_graphql('test')
end
context 'when pagination options present' do
context 'with "first" option' do
let(:expected_graphql_params) do
"type: REPOSITORY, query: \"#{expected_query}\", first: 25"
end
it 'searches for repositories via expected query' do
expect(client.octokit).to receive(:post).with(
'/graphql', { query: expected_graphql }.to_json
)
client.search_repos_by_name_graphql('test', { first: 25 })
end
end
context 'with "after" option' do
let(:expected_graphql_params) do
"type: REPOSITORY, query: \"#{expected_query}\", after: \"Y3Vyc29yOjE=\""
end
it 'searches for repositories via expected query' do
expect(client.octokit).to receive(:post).with(
'/graphql', { query: expected_graphql }.to_json
)
client.search_repos_by_name_graphql('test', { after: 'Y3Vyc29yOjE=' })
end
end
end
context 'when Faraday error received from octokit', :aggregate_failures do
let(:error_class) { described_class::CLIENT_CONNECTION_ERROR }
let(:info_params) { { 'error.class': error_class } }
it 'retries on error and succeeds' do
allow_retry(:post)
expect(Gitlab::Import::Logger).to receive(:info).with(hash_including(info_params)).once
expect(client.search_repos_by_name_graphql('test')).to eq({})
end
it 'retries and does not succeed' do
allow(client.octokit)
.to receive(:post)
.with('/graphql', { query: expected_graphql }.to_json)
.and_raise(error_class, 'execution expired')
expect { client.search_repos_by_name_graphql('test') }.to raise_error(error_class, 'execution expired')
end
end
end
describe '#search_repos_by_name' do
let(:expected_query) { 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2' }
it 'searches for repositories based on name' do
expect(client.octokit).to receive(:search_repositories).with(expected_query, {})
client.search_repos_by_name('test')
end
context 'when pagination options present' do
it 'searches for repositories via expected query' do
expect(client.octokit).to receive(:search_repositories).with(
expected_query, page: 2, per_page: 25
)
client.search_repos_by_name('test', { page: 2, per_page: 25 })
end
end
context 'when Faraday error received from octokit', :aggregate_failures do
let(:error_class) { described_class::CLIENT_CONNECTION_ERROR }
let(:info_params) { { 'error.class': error_class } }
@ -601,36 +699,15 @@ RSpec.describe Gitlab::GithubImport::Client do
end
it 'retries and does not succeed' do
allow(client.octokit).to receive(:search_repositories).and_raise(error_class, 'execution expired')
allow(client.octokit)
.to receive(:search_repositories)
.with(expected_query, {})
.and_raise(error_class, 'execution expired')
expect { client.search_repos_by_name('test') }.to raise_error(error_class, 'execution expired')
end
end
end
describe '#search_query' do
it 'returns base search query' do
result = client.search_query(str: 'test', type: :test, include_collaborations: false, include_orgs: false)
expect(result).to eq('test in:test is:public,private user:user')
end
context 'when include_collaborations is true' do
it 'returns search query including users' do
result = client.search_query(str: 'test', type: :test, include_collaborations: true, include_orgs: false)
expect(result).to eq('test in:test is:public,private user:user repo:repo1 repo:repo2')
end
end
context 'when include_orgs is true' do
it 'returns search query including users' do
result = client.search_query(str: 'test', type: :test, include_collaborations: false, include_orgs: true)
expect(result).to eq('test in:test is:public,private user:user org:org1 org:org2')
end
end
end
end
def allow_retry(method = :pull_request)

View File

@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::Clients::Proxy, :manage, feature_category: :import do
subject(:client) { described_class.new(access_token, client_options) }
let(:access_token) { 'test_token' }
let(:client_options) { { foo: :bar } }
describe '#repos' do
let(:search_text) { 'search text' }
let(:pagination_options) { { limit: 10 } }
context 'when remove_legacy_github_client FF is enabled' do
let(:client_stub) { instance_double(Gitlab::GithubImport::Client) }
context 'with github_client_fetch_repos_via_graphql FF enabled' do
let(:client_response) do
{
data: {
search: {
nodes: [{ name: 'foo' }, { name: 'bar' }],
pageInfo: { startCursor: 'foo', endCursor: 'bar' }
}
}
}
end
it 'fetches repos with Gitlab::GithubImport::Client (GraphQL API)' do
expect(Gitlab::GithubImport::Client)
.to receive(:new).with(access_token).and_return(client_stub)
expect(client_stub)
.to receive(:search_repos_by_name_graphql)
.with(search_text, pagination_options).and_return(client_response)
expect(client.repos(search_text, pagination_options)).to eq(
{
repos: [{ name: 'foo' }, { name: 'bar' }],
page_info: { startCursor: 'foo', endCursor: 'bar' }
}
)
end
end
context 'with github_client_fetch_repos_via_graphql FF disabled' do
let(:client_response) do
{ items: [{ name: 'foo' }, { name: 'bar' }] }
end
before do
stub_feature_flags(github_client_fetch_repos_via_graphql: false)
end
it 'fetches repos with Gitlab::GithubImport::Client (REST API)' do
expect(Gitlab::GithubImport::Client)
.to receive(:new).with(access_token).and_return(client_stub)
expect(client_stub)
.to receive(:search_repos_by_name)
.with(search_text, pagination_options).and_return(client_response)
expect(client.repos(search_text, pagination_options)).to eq(
{ repos: [{ name: 'foo' }, { name: 'bar' }] }
)
end
end
end
context 'when remove_legacy_github_client FF is disabled' do
let(:client_stub) { instance_double(Gitlab::LegacyGithubImport::Client) }
let(:search_text) { nil }
before do
stub_feature_flags(remove_legacy_github_client: false)
end
it 'fetches repos with Gitlab::LegacyGithubImport::Client' do
expect(Gitlab::LegacyGithubImport::Client)
.to receive(:new).with(access_token, client_options).and_return(client_stub)
expect(client_stub).to receive(:repos)
.and_return([{ name: 'foo' }, { name: 'bar' }])
expect(client.repos(search_text, pagination_options))
.to eq({ repos: [{ name: 'foo' }, { name: 'bar' }] })
end
context 'with filter params' do
let(:search_text) { 'fo' }
it 'fetches repos with Gitlab::LegacyGithubImport::Client' do
expect(Gitlab::LegacyGithubImport::Client)
.to receive(:new).with(access_token, client_options).and_return(client_stub)
expect(client_stub).to receive(:repos)
.and_return([{ name: 'FOO' }, { name: 'bAr' }])
expect(client.repos(search_text, pagination_options))
.to eq({ repos: [{ name: 'FOO' }] })
end
end
end
end
end

View File

@ -39,11 +39,16 @@ RSpec.describe ServiceDeskSetting do
let_it_be(:project1) { create(:project, name: 'test-one', group: group) }
let_it_be(:project2) { create(:project, name: 'one', group: subgroup) }
let_it_be(:project_key) { 'key' }
before_all do
let!(:setting) do
create(:service_desk_setting, project: project1, project_key: project_key)
end
context 'when project_key exists' do
it 'is valid' do
expect(setting).to be_valid
end
end
context 'when project_key is unique for every project slug' do
it 'does not add error' do
settings = build(:service_desk_setting, project: project2, project_key: 'otherkey')

View File

@ -623,14 +623,6 @@ RSpec.describe ProjectPresenter do
context 'empty repo' do
let(:project) { create(:project, :stubbed_repository) }
context 'for a guest user' do
it 'orders the items correctly' do
expect(empty_repo_statistics_buttons.map(&:label)).to start_with(
a_string_including('No license')
)
end
end
it 'includes a button to configure integrations for maintainers' do
project.add_maintainer(user)

View File

@ -30,6 +30,7 @@ RSpec.describe 'Query.project.pipelineSchedules', feature_category: :continuous_
cron
cronTimezone
editPath
variables { nodes { #{all_graphql_fields_for('PipelineScheduleVariable')} } }
}
QUERY
end
@ -70,6 +71,38 @@ RSpec.describe 'Query.project.pipelineSchedules', feature_category: :continuous_
end
end
describe 'variables' do
let!(:env_vars) { create_list(:ci_pipeline_schedule_variable, 5, pipeline_schedule: pipeline_schedule) }
it 'returns all variables' do
post_graphql(query, current_user: user)
variables = pipeline_schedule_graphql_data['variables']['nodes']
expected = env_vars.map do |var|
a_graphql_entity_for(var, :key, :value, variable_type: var.variable_type.upcase)
end
expect(variables).to match_array(expected)
end
it 'is N+1 safe on the variables level' do
baseline = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: user) }
create_list(:ci_pipeline_schedule_variable, 2, pipeline_schedule: pipeline_schedule)
expect { post_graphql(query, current_user: user) }.not_to exceed_query_limit(baseline)
end
it 'is N+1 safe on the schedules level' do
baseline = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: user) }
pipeline_schedule_2 = create(:ci_pipeline_schedule, project: project, owner: user)
create_list(:ci_pipeline_schedule_variable, 2, pipeline_schedule: pipeline_schedule_2)
expect { post_graphql(query, current_user: user) }.not_to exceed_query_limit(baseline)
end
end
describe 'permissions' do
let_it_be(:another_user) { create(:user) }

View File

@ -0,0 +1,151 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'PipelineSchedulecreate' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let(:mutation) do
variables = {
project_path: project.full_path,
**pipeline_schedule_parameters
}
graphql_mutation(
:pipeline_schedule_create,
variables,
<<-QL
pipelineSchedule {
id
description
cron
refForDisplay
active
cronTimezone
variables {
nodes {
key
value
}
}
owner {
id
}
}
errors
QL
)
end
let(:pipeline_schedule_parameters) do
{
description: 'created_desc',
cron: '0 1 * * *',
cronTimezone: 'UTC',
ref: 'patch-x',
active: true,
variables: [
{ key: 'AAA', value: "AAA123", variableType: 'ENV_VAR' }
]
}
end
let(:mutation_response) { graphql_mutation_response(:pipeline_schedule_create) }
context 'when unauthorized' do
it 'returns an error' do
post_graphql_mutation(mutation, current_user: user)
expect(graphql_errors).not_to be_empty
expect(graphql_errors[0]['message'])
.to eq(
"The resource that you are attempting to access does not exist " \
"or you don't have permission to perform this action"
)
end
end
context 'when authorized' do
before do
project.add_developer(user)
end
context 'when success' do
it do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['pipelineSchedule']['owner']['id']).to eq(user.to_global_id.to_s)
%w[description cron cronTimezone active].each do |key|
expect(mutation_response['pipelineSchedule'][key]).to eq(pipeline_schedule_parameters[key.to_sym])
end
expect(mutation_response['pipelineSchedule']['refForDisplay']).to eq(pipeline_schedule_parameters[:ref])
expect(mutation_response['pipelineSchedule']['variables']['nodes'][0]['key']).to eq('AAA')
expect(mutation_response['pipelineSchedule']['variables']['nodes'][0]['value']).to eq('AAA123')
expect(mutation_response['pipelineSchedule']['owner']['id']).to eq(user.to_global_id.to_s)
expect(mutation_response['errors']).to eq([])
end
end
context 'when failure' do
context 'when params are invalid' do
let(:pipeline_schedule_parameters) do
{
description: 'some description',
cron: 'abc',
cronTimezone: 'cCc',
ref: 'asd',
active: true,
variables: []
}
end
it do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors'])
.to match_array(
["Cron is invalid syntax", "Cron timezone is invalid syntax"]
)
end
end
context 'when variables have duplicate name' do
before do
pipeline_schedule_parameters.merge!(
{
variables: [
{ key: 'AAA', value: "AAA123", variableType: 'ENV_VAR' },
{ key: 'AAA', value: "AAA123", variableType: 'ENV_VAR' }
]
}
)
end
it 'returns error' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors'])
.to match_array(
[
"Variables have duplicate values (AAA)"
]
)
end
end
end
end
end