Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
a64e7a4066
commit
17c478bc80
2
Gemfile
2
Gemfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"CiGroupVariable",
|
||||
"CiInstanceVariable",
|
||||
"CiManualVariable",
|
||||
"CiProjectVariable"
|
||||
"CiProjectVariable",
|
||||
"PipelineScheduleVariable"
|
||||
],
|
||||
"CommitSignature": [
|
||||
"GpgSignature",
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@ export const STATUSES = {
|
|||
CANCELLED: 'cancelled',
|
||||
TIMEOUT: 'timeout',
|
||||
};
|
||||
|
||||
export const PROVIDERS = {
|
||||
GITHUB: 'github',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export default {
|
|||
</table>
|
||||
</div>
|
||||
<gl-intersection-observer
|
||||
v-if="paginatable"
|
||||
v-if="paginatable && pageInfo.hasNextPage"
|
||||
:key="pagePaginationStateKey"
|
||||
@appear="fetchRepos"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,5 +7,8 @@ export default () => ({
|
|||
filter: '',
|
||||
pageInfo: {
|
||||
page: 0,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasNextPage: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ci
|
||||
class PipelineScheduleVariablePolicy < BasePolicy
|
||||
delegate :pipeline_schedule
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue