Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-12-15 03:08:38 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-12-15 03:08:38 +0300
commit17c478bc8096b22f22a4b1d8573540d2dfddf6ba (patch)
tree4b697e0ea543fe27154d6ffd2070edd5047d3947
parenta64e7a40667471a1a6594df04476b3c99cabbe3c (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.checksum2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json3
-rw-r--r--app/assets/javascripts/import_entities/constants.js4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue17
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js28
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js5
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/state.js3
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue108
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form_actions.vue143
-rw-r--r--app/controllers/import/gitea_controller.rb24
-rw-r--r--app/controllers/import/github_controller.rb48
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/create.rb72
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb19
-rw-r--r--app/graphql/types/ci/pipeline_schedule_type.rb45
-rw-r--r--app/graphql/types/ci/pipeline_schedule_variable_type.rb13
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/models/protected_branch.rb6
-rw-r--r--app/models/service_desk_setting.rb2
-rw-r--r--app/policies/ci/pipeline_schedule_variable_policy.rb7
-rw-r--r--app/presenters/project_presenter.rb4
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml4
-rw-r--r--app/views/admin/application_settings/_search_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml4
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml4
-rw-r--r--config/feature_flags/development/github_client_fetch_repos_via_graphql.yml8
-rw-r--r--doc/api/graphql/reference/index.md82
-rw-r--r--doc/user/application_security/dependency_scanning/index.md9
-rw-r--r--doc/user/packages/pypi_repository/index.md12
-rw-r--r--lib/gitlab/github_import/client.rb14
-rw-r--r--lib/gitlab/github_import/clients/proxy.rb59
-rw-r--r--lib/gitlab/github_import/clients/search_repos.rb66
-rw-r--r--locale/gitlab.pot12
-rw-r--r--qa/qa/page/project/infrastructure/kubernetes/show.rb3
-rw-r--r--qa/qa/page/project/settings/services/jenkins.rb2
-rw-r--r--qa/qa/page/project/settings/services/jira.rb2
-rw-r--r--qa/qa/page/project/settings/services/pipeline_status_emails.rb3
-rw-r--r--spec/controllers/import/github_controller_spec.rb270
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js59
-rw-r--r--spec/frontend/import_entities/import_projects/store/actions_spec.js121
-rw-r--r--spec/frontend/import_entities/import_projects/store/mutations_spec.js10
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_actions_spec.js227
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js472
-rw-r--r--spec/graphql/types/ci/pipeline_schedule_type_spec.rb7
-rw-r--r--spec/graphql/types/ci/pipeline_schedule_variable_type_spec.rb17
-rw-r--r--spec/lib/gitlab/github_import/client_spec.rb117
-rw-r--r--spec/lib/gitlab/github_import/clients/proxy_spec.rb102
-rw-r--r--spec/models/service_desk_setting_spec.rb9
-rw-r--r--spec/presenters/project_presenter_spec.rb8
-rw-r--r--spec/requests/api/graphql/ci/pipeline_schedules_spec.rb33
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_schedule_create_spec.rb151
55 files changed, 1743 insertions, 714 deletions
diff --git a/Gemfile b/Gemfile
index 194cef7e0a8..8416cdf56de 100644
--- a/Gemfile
+++ b/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
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 03df98f1079..a7701c00689 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -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"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 0e8fc6f39cf..d750db9768b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 1e65558b814..5467105ac3c 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -7,7 +7,8 @@
"CiGroupVariable",
"CiInstanceVariable",
"CiManualVariable",
- "CiProjectVariable"
+ "CiProjectVariable",
+ "PipelineScheduleVariable"
],
"CommitSignature": [
"GpgSignature",
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index c470da21765..d0a23f88ee7 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -12,3 +12,7 @@ export const STATUSES = {
CANCELLED: 'cancelled',
TIMEOUT: 'timeout',
};
+
+export const PROVIDERS = {
+ GITHUB: 'github',
+};
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 40eba0a19d2..6412f26fde7 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -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];
},
},
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 701384f14a7..d82acfa3f05 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -203,7 +203,7 @@ export default {
</table>
</div>
<gl-intersection-observer
- v-if="paginatable"
+ v-if="paginatable && pageInfo.hasNextPage"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js
index 74723ec0e9d..ba51e1aacd3 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js
@@ -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)) {
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
index 43dcd54c239..360582de2db 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index fda0ed47c0f..a7cbace0725 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -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 };
+ },
};
diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js
index 010f462e385..c384848f0a0 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/state.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/state.js
@@ -7,5 +7,8 @@ export default () => ({
filter: '',
pageInfo: {
page: 0,
+ startCursor: null,
+ endCursor: null,
+ hasNextPage: true,
},
});
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index a9cb569d823..d86e6326f64 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -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,
+ isValidated: false,
isSaving: false,
+ isTesting: false,
isResetting: false,
- isValidated: 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>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
new file mode 100644
index 00000000000..e5ad5149cf7
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
@@ -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>
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 97317b7c376..61e32650db3 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -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
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 92763e09ba3..cb58b5974ca 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -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
}
diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb
new file mode 100644
index 00000000000..65b355cd80f
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb
@@ -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
diff --git a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb
new file mode 100644
index 00000000000..54a6ad92448
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb
@@ -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
diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb
index ef7ec7399e5..904fa3f1c72 100644
--- a/app/graphql/types/ci/pipeline_schedule_type.rb
+++ b/app/graphql/types/ci/pipeline_schedule_type.rb
@@ -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 :variables,
+ Types::Ci::PipelineScheduleVariableType.connection_type,
+ null: true,
+ description: 'Pipeline schedule variables.',
+ authorize: :read_pipeline_schedule_variables
+
+ 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 :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 :edit_path, GraphQL::Types::String, null: true, description: 'Edit path of the pipeline schedule.'
+ 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
diff --git a/app/graphql/types/ci/pipeline_schedule_variable_type.rb b/app/graphql/types/ci/pipeline_schedule_variable_type.rb
new file mode 100644
index 00000000000..1cb407bc2e4
--- /dev/null
+++ b/app/graphql/types/ci/pipeline_schedule_variable_type.rb
@@ -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
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 0d36bfcf0f3..b392173098f 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -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',
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 80967c1b072..c59ef4cd80b 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -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
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index 6dd7415d928..738f18ca5e3 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -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|
diff --git a/app/policies/ci/pipeline_schedule_variable_policy.rb b/app/policies/ci/pipeline_schedule_variable_policy.rb
new file mode 100644
index 00000000000..dbbf9221e77
--- /dev/null
+++ b/app/policies/ci/pipeline_schedule_variable_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineScheduleVariablePolicy < BasePolicy
+ delegate :pipeline_schedule
+ end
+end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 8ae47803a46..4d1a9b3f589 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -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
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index d962d050ebc..b301ec15a0e 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -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
diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml
index 945c9397f0d..396c263dd5d 100644
--- a/app/views/admin/application_settings/_search_limits.html.haml
+++ b/app/views/admin/application_settings/_search_limits.html.haml
@@ -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
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index c9c73d9f997..9c8770b8998 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -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
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index ba7687db9c7..ad78c677da1 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -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
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index 86f151f8288..48334023cf0 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -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
diff --git a/config/feature_flags/development/github_client_fetch_repos_via_graphql.yml b/config/feature_flags/development/github_client_fetch_repos_via_graphql.yml
new file mode 100644
index 00000000000..7ff87410458
--- /dev/null
+++ b/config/feature_flags/development/github_client_fetch_repos_via_graphql.yml
@@ -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
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index ad53715b57c..d6a029f7cd9 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -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.
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index b4e6e058e6d..498d577178f 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -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
diff --git a/doc/user/packages/pypi_repository/index.md b/doc/user/packages/pypi_repository/index.md
index a4ec8be5b70..0e2fc7ca7da 100644
--- a/doc/user/packages/pypi_repository/index.md
+++ b/doc/user/packages/pypi_repository/index.md
@@ -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:
diff --git a/lib/gitlab/github_import/client.rb b/lib/gitlab/github_import/client.rb
index d6060141bce..065410693e5 100644
--- a/lib/gitlab/github_import/client.rb
+++ b/lib/gitlab/github_import/client.rb
@@ -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?
diff --git a/lib/gitlab/github_import/clients/proxy.rb b/lib/gitlab/github_import/clients/proxy.rb
new file mode 100644
index 00000000000..f6d1c8ed23c
--- /dev/null
+++ b/lib/gitlab/github_import/clients/proxy.rb
@@ -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
diff --git a/lib/gitlab/github_import/clients/search_repos.rb b/lib/gitlab/github_import/clients/search_repos.rb
new file mode 100644
index 00000000000..bcd226087e7
--- /dev/null
+++ b/lib/gitlab/github_import/clients/search_repos.rb
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ef06735ad80..4ee98b3623e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/qa/qa/page/project/infrastructure/kubernetes/show.rb b/qa/qa/page/project/infrastructure/kubernetes/show.rb
index 6de5024e525..8725f64fe32 100644
--- a/qa/qa/page/project/infrastructure/kubernetes/show.rb
+++ b/qa/qa/page/project/infrastructure/kubernetes/show.rb
@@ -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
diff --git a/qa/qa/page/project/settings/services/jenkins.rb b/qa/qa/page/project/settings/services/jenkins.rb
index 39403995ce8..a9b5c84f9ee 100644
--- a/qa/qa/page/project/settings/services/jenkins.rb
+++ b/qa/qa/page/project/settings/services/jenkins.rb
@@ -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
diff --git a/qa/qa/page/project/settings/services/jira.rb b/qa/qa/page/project/settings/services/jira.rb
index 41034bbd897..7a62b111f98 100644
--- a/qa/qa/page/project/settings/services/jira.rb
+++ b/qa/qa/page/project/settings/services/jira.rb
@@ -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
diff --git a/qa/qa/page/project/settings/services/pipeline_status_emails.rb b/qa/qa/page/project/settings/services/pipeline_status_emails.rb
index 2f78577e3d5..3edd1d61d76 100644
--- a/qa/qa/page/project/settings/services/pipeline_status_emails.rb
+++ b/qa/qa/page/project/settings/services/pipeline_status_emails.rb
@@ -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
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index 9c2c90b147c..a85af89b262 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -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)
+ 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
- it 'fetches repos using legacy client' do
- expect_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client|
- expect(client).to receive(:repos).and_return([])
- end
+ 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
- get :status
+ let!(:imported_project) do
+ create(
+ :project,
+ import_type: provider, namespace: user.namespace,
+ import_status: :finished, import_source: 'example/repo'
+ )
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
+ it 'responds with expected high-level structure' do
+ get :status, format: :json
- get :new
+ 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
- context 'when feature remove_legacy_github_client is enabled' do
+ 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
+ expect(response).to have_gitlab_http_status :ok
+ expect(assigns(:namespace)).to eq(namespace)
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
+ context 'when user is not allowed to create projects in this namespace' do
+ let(:namespace) { create(:namespace) }
- 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
+ it 'renders 404' do
+ get :status, params: { namespace_id: namespace.id }, format: :html
- get :status, params: { page: 2 }
- end
+ expect(response).to have_gitlab_http_status :not_found
end
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}" }
-
- before do
- allow_next_instance_of(Octokit::Client) do |client|
- allow(client).to receive(:user).and_return(double(login: user_login))
- end
- end
+ context 'pagination' do
+ context 'when cursor is specified' do
+ let(:pagination_params) { { before: nil, after: 'CURSOR' } }
+ let(:params) { pagination_params }
- 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
+ it_behaves_like 'calls repos through Clients::Proxy with expected args'
+ end
- 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
+ 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 }
- get :status, params: { filter: filter }, format: :json
- end
+ 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
-
- 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
- end
- end
+ context 'when filtering' do
+ let(:filter_param) { FFaker::Lorem.word }
+ let(:params) { { filter: filter_param } }
+ let(:expected_filter) { filter_param }
- 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_behaves_like 'calls repos through Clients::Proxy with expected args'
- 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
+ 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) }
- get :status, params: { filter: filter, page: 2 }, format: :json
- end
- end
+ it_behaves_like 'calls repos through Clients::Proxy with expected args'
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
+ context 'when after cursor present' do
+ let(:pagination_params) { { before: nil, after: 'after-cursor' } }
+ let(:params) { { filter: filter_param }.merge(pagination_params) }
- it 'sanitizes user input' do
- filter = ' test1:test2 test3 : test4 '
- expected_filter = 'test1test2test3test4'
+ it_behaves_like 'calls repos through Clients::Proxy with expected args'
+ end
+ end
- get :status, params: { filter: filter }, format: :json
+ context 'when user input contains colons and spaces' do
+ let(:filter_param) { ' test1:test2 test3 : test4 ' }
+ let(:expected_filter) { 'test1test2test3test4' }
- expect(assigns(:filter)).to eq(expected_filter)
- end
- end
+ it_behaves_like 'calls repos through Clients::Proxy with expected args'
+ 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 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
+ it 'returns 429' do
+ get :status, 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
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 8b695f188cc..37cd0a609bd 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -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 },
+ ],
+ };
+
+ describe('with hasNextPage true', () => {
+ beforeEach(() => {
+ createComponent({
+ state: initState,
+ paginatable: true,
+ });
+ });
- beforeEach(() => {
- createComponent({
- state: {
- namespaces: [{ fullPath: 'path' }],
- pageInfo,
- repositories: [
- { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
- ],
- },
- paginatable: true,
+ it('does not call fetchRepos on mount', () => {
+ expect(fetchReposFn).not.toHaveBeenCalled();
});
- });
- 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();
- it('renders intersection observer component', () => {
- expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true);
+ expect(fetchReposFn).toHaveBeenCalled();
+ });
});
- it('calls fetchRepos when intersection observer appears', async () => {
- wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
+ describe('with hasNextPage false', () => {
+ beforeEach(() => {
+ initState.pageInfo.hasNextPage = false;
- await nextTick();
+ createComponent({
+ state: initState,
+ paginatable: true,
+ });
+ });
- expect(fetchReposFn).toHaveBeenCalled();
+ it('does not render intersection observer component', () => {
+ expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js
index 945ba64e5e2..8e2e4d7c1ac 100644
--- a/spec/frontend/import_entities/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js
@@ -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;
+ });
+
+ it('includes cursor in url query params', async () => {
+ let requestedUrl;
+ mock.onGet().reply((config) => {
+ requestedUrl = config.url;
+ return [200, payload];
+ });
+
+ const localStateWithPage = { ...localState, pageInfo: { endCursor: 'endTest' } };
- await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array));
+ await testAction(
+ fetchRepos,
+ null,
+ localStateWithPage,
+ expect.any(Array),
+ expect.any(Array),
+ );
- expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
+ expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?after=endTest`);
+ });
+ });
});
it('correctly keeps current page on an unsuccessful request', () => {
diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
index b8970916420..16c6d74d1e8 100644
--- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
@@ -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 });
+ });
+ });
});
diff --git a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js
new file mode 100644
index 00000000000..e95e30a1899
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js
@@ -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);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index 17a08da0a07..4b49e492880 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -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,111 +353,85 @@ 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'));
- });
-
- it('sets save button `loading` prop to `true`', () => {
- expect(findProjectSaveButton().props('loading')).toBe(true);
+ 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 test button `disabled` prop to `true`', () => {
- expect(findTestButton().props('disabled')).toBe(true);
- });
- });
+ findFormActions().vm.$emit('save');
+ await nextTick();
+ };
- 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);
-
- await findProjectSaveButton().vm.$emit('click', new Event('click'));
- });
+ 'when $desc (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)',
+ async ({ integrationActive, checkValidityReturn, shouldSubmit }) => {
+ await prepareComponentAndSave(integrationActive, checkValidityReturn);
- 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)', () => {
- 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('flips `isSaving` to `true`', async () => {
+ await prepareComponentAndSave(true, true);
+ expect(findFormActions().props('isSaving')).toBe(true);
+ });
- it('sets save button `loading` prop to `false`', () => {
- expect(findProjectSaveButton().props('loading')).toBe(false);
+ describe('when form is invalid', () => {
+ beforeEach(async () => {
+ await prepareComponentAndSave(true, false);
});
- it('sets test button `disabled` prop to `false`', () => {
- expect(findTestButton().props('disabled')).toBe(false);
+ it('when form is invalid, it sets `isValidated` props on form fields', () => {
+ expect(findDynamicField().props('isValidated')).toBe(true);
});
- it('sets `isValidated` props on form fields', () => {
- expect(findDynamicField().props('isValidated')).toBe(true);
+ it('resets `isSaving`', () => {
+ expect(findFormActions().props('isSaving')).toBe(false);
});
});
});
- describe('when `test` button is clicked', () => {
+ describe('Response to the "test" event from the actions', () => {
describe('when form is invalid', () => {
- it('sets `isValidated` props on form fields', async () => {
+ beforeEach(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'));
+ findFormActions().vm.$emit('test');
+ await nextTick();
+ });
+ it('sets `isValidated` props on form fields', () => {
expect(findDynamicField().props('isValidated')).toBe(true);
});
+
+ it('resets `isTesting`', () => {
+ expect(findFormActions().props('isTesting')).toBe(false);
+ });
});
describe('when form is valid', () => {
@@ -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);
- }
- },
- );
+ it('resets `isResetting`', async () => {
+ expect(findFormActions().props('isResetting')).toBe(false);
});
+ });
+ });
- 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);
- },
- );
- });
+ 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);
+ },
+ );
});
});
});
diff --git a/spec/graphql/types/ci/pipeline_schedule_type_spec.rb b/spec/graphql/types/ci/pipeline_schedule_type_spec.rb
index e14ff453be0..6e6c6c63969 100644
--- a/spec/graphql/types/ci/pipeline_schedule_type_spec.rb
+++ b/spec/graphql/types/ci/pipeline_schedule_type_spec.rb
@@ -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)
diff --git a/spec/graphql/types/ci/pipeline_schedule_variable_type_spec.rb b/spec/graphql/types/ci/pipeline_schedule_variable_type_spec.rb
new file mode 100644
index 00000000000..1c98539e308
--- /dev/null
+++ b/spec/graphql/types/ci/pipeline_schedule_variable_type_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb
index 95f7933fbc5..625212da394 100644
--- a/spec/lib/gitlab/github_import/client_spec.rb
+++ b/spec/lib/gitlab/github_import/client_spec.rb
@@ -579,13 +579,69 @@ RSpec.describe Gitlab::GithubImport::Client do
allow(client.octokit).to receive(:user).and_return(user)
end
- describe '#search_repos_by_name' do
+ 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
+
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'
+ expect(client.octokit).to receive(:post).with(
+ '/graphql', { query: expected_graphql }.to_json
+ )
- expect(client.octokit).to receive(:search_repositories).with(expected_search_query, {})
+ client.search_repos_by_name_graphql('test')
+ end
- client.search_repos_by_name('test')
+ 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
@@ -593,41 +649,62 @@ RSpec.describe Gitlab::GithubImport::Client do
let(:info_params) { { 'error.class': error_class } }
it 'retries on error and succeeds' do
- allow_retry(:search_repositories)
+ allow_retry(:post)
expect(Gitlab::Import::Logger).to receive(:info).with(hash_including(info_params)).once
- expect(client.search_repos_by_name('test')).to eq({})
+ expect(client.search_repos_by_name_graphql('test')).to eq({})
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(:post)
+ .with('/graphql', { query: expected_graphql }.to_json)
+ .and_raise(error_class, 'execution expired')
- expect { client.search_repos_by_name('test') }.to raise_error(error_class, 'execution expired')
+ expect { client.search_repos_by_name_graphql('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)
+ 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' }
- expect(result).to eq('test in:test is:public,private user:user')
+ 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 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)
+ 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
+ )
- expect(result).to eq('test in:test is:public,private user:user repo:repo1 repo:repo2')
+ client.search_repos_by_name('test', { page: 2, per_page: 25 })
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)
+ 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(:search_repositories)
+
+ expect(Gitlab::Import::Logger).to receive(:info).with(hash_including(info_params)).once
+
+ expect(client.search_repos_by_name('test')).to eq({})
+ end
+
+ it 'retries and does not succeed' do
+ allow(client.octokit)
+ .to receive(:search_repositories)
+ .with(expected_query, {})
+ .and_raise(error_class, 'execution expired')
- expect(result).to eq('test in:test is:public,private user:user org:org1 org:org2')
+ expect { client.search_repos_by_name('test') }.to raise_error(error_class, 'execution expired')
end
end
end
diff --git a/spec/lib/gitlab/github_import/clients/proxy_spec.rb b/spec/lib/gitlab/github_import/clients/proxy_spec.rb
new file mode 100644
index 00000000000..9fef57f2a38
--- /dev/null
+++ b/spec/lib/gitlab/github_import/clients/proxy_spec.rb
@@ -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
diff --git a/spec/models/service_desk_setting_spec.rb b/spec/models/service_desk_setting_spec.rb
index f99ac84175c..c1ec35732b8 100644
--- a/spec/models/service_desk_setting_spec.rb
+++ b/spec/models/service_desk_setting_spec.rb
@@ -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')
diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb
index 378a7ef8dd6..4c2b87f34a1 100644
--- a/spec/presenters/project_presenter_spec.rb
+++ b/spec/presenters/project_presenter_spec.rb
@@ -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)
diff --git a/spec/requests/api/graphql/ci/pipeline_schedules_spec.rb b/spec/requests/api/graphql/ci/pipeline_schedules_spec.rb
index e1d91587464..76adce6ff1b 100644
--- a/spec/requests/api/graphql/ci/pipeline_schedules_spec.rb
+++ b/spec/requests/api/graphql/ci/pipeline_schedules_spec.rb
@@ -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) }
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_schedule_create_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_schedule_create_spec.rb
new file mode 100644
index 00000000000..4a45d255d99
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_schedule_create_spec.rb
@@ -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