diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-15 03:08:38 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-15 03:08:38 +0300 |
commit | 17c478bc8096b22f22a4b1d8573540d2dfddf6ba (patch) | |
tree | 4b697e0ea543fe27154d6ffd2070edd5047d3947 | |
parent | a64e7a40667471a1a6594df04476b3c99cabbe3c (diff) |
Add latest changes from gitlab-org/gitlab@master
55 files changed, 1743 insertions, 714 deletions
@@ -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 |