diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-13 21:10:20 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-13 21:10:20 +0300 |
commit | b1928c08f1642be0f66f6fa2587177b95a1cedc1 (patch) | |
tree | 35cc089bc6692db0135437fe7834928fc64052fe | |
parent | bd25f1d9c685039381df23e49bc52cdcf4ec1b4a (diff) |
Add latest changes from gitlab-org/gitlab@master
51 files changed, 1226 insertions, 423 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2b3230cbb72..13531566631 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,6 +56,11 @@ workflow: NOTIFY_PIPELINE_FAILURE_CHANNEL: "f_ruby3" OMNIBUS_GITLAB_RUBY3_BUILD: "true" OMNIBUS_GITLAB_CACHE_EDITION: "GITLAB_RUBY3" + # This work around https://gitlab.com/gitlab-org/gitlab/-/issues/332411 whichs prevents usage of dependency proxy + # when pipeline is triggered by a project access token. + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $GITLAB_USER_LOGIN =~ /project_\d+_bot\d*/' + variables: + GITLAB_DEPENDENCY_PROXY_ADDRESS: "" # For `$CI_DEFAULT_BRANCH` branch, create a pipeline (this includes on schedules, pushes, merges, etc.). - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # For tags, create a pipeline. @@ -71,6 +76,9 @@ workflow: variables: PG_VERSION: "12" DEFAULT_CI_IMAGE: "${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}.patched-golang-${GO_VERSION}-node-16.14-postgresql-${PG_VERSION}:rubygems-3.2-git-2.36-lfs-2.9-chrome-${CHROME_VERSION}-yarn-1.22-graphicsmagick-1.3.36" + # We set $GITLAB_DEPENDENCY_PROXY to another variable (since it's set at the group level and has higher precedence than .gitlab-ci.yml) + # so that we can override $GITLAB_DEPENDENCY_PROXY_ADDRESS in workflow rules. + GITLAB_DEPENDENCY_PROXY_ADDRESS: "${GITLAB_DEPENDENCY_PROXY}" RAILS_ENV: "test" NODE_ENV: "test" BUNDLE_WITHOUT: "production:development" diff --git a/.gitlab/ci/_skip.yml b/.gitlab/ci/_skip.yml index 27a3ff5b836..9d3745cf2f1 100644 --- a/.gitlab/ci/_skip.yml +++ b/.gitlab/ci/_skip.yml @@ -1,7 +1,7 @@ # no-op pipeline template for skipping whole child pipeline execution no-op: - image: ${GITLAB_DEPENDENCY_PROXY}alpine:latest + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}alpine:latest stage: test variables: GIT_STRATEGY: none diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 7e157171183..022f1c17a93 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -2,7 +2,7 @@ extends: - .default-retry - .docs:rules:review-docs - image: ${GITLAB_DEPENDENCY_PROXY}ruby:${RUBY_VERSION}-alpine + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}-alpine stage: review needs: [] variables: diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index c64704d5d49..00ac68782e6 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -301,17 +301,17 @@ coverage-frontend: qa-frontend-node:14: extends: .qa-frontend-node - image: ${GITLAB_DEPENDENCY_PROXY}node:14 + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}node:14 qa-frontend-node:16: extends: .qa-frontend-node - image: ${GITLAB_DEPENDENCY_PROXY}node:16 + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}node:16 qa-frontend-node:latest: extends: - .qa-frontend-node - .frontend:rules:qa-frontend-node-latest - image: ${GITLAB_DEPENDENCY_PROXY}node:latest + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}node:latest webpack-dev-server: extends: diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index 77e17b2147f..9be5eb7bcd7 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -342,7 +342,7 @@ FOSS_ONLY: '1' .use-docker-in-docker: - image: ${GITLAB_DEPENDENCY_PROXY}docker:${DOCKER_VERSION} + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}docker:${DOCKER_VERSION} services: - docker:${DOCKER_VERSION}-dind variables: diff --git a/.gitlab/ci/notify.gitlab-ci.yml b/.gitlab/ci/notify.gitlab-ci.yml index 95318d5ce08..c945d4dc780 100644 --- a/.gitlab/ci/notify.gitlab-ci.yml +++ b/.gitlab/ci/notify.gitlab-ci.yml @@ -1,5 +1,5 @@ .notify-slack: - image: ${GITLAB_DEPENDENCY_PROXY}alpine + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}alpine stage: notify dependencies: [] cache: {} diff --git a/.gitlab/ci/releases.gitlab-ci.yml b/.gitlab/ci/releases.gitlab-ci.yml index 77f23814f3c..df7b07f5545 100644 --- a/.gitlab/ci/releases.gitlab-ci.yml +++ b/.gitlab/ci/releases.gitlab-ci.yml @@ -4,7 +4,7 @@ .merge-train-sync: # We don't need/want any global before/after commands, so we overwrite these # settings. - image: ${GITLAB_DEPENDENCY_PROXY}alpine:edge + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}alpine:edge stage: sync before_script: - apk add --no-cache --update curl bash jq diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 201c32741f9..d3f5d014464 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -32,7 +32,7 @@ review-build-cng-env: extends: - .default-retry - .review:rules:review-build-cng - image: ${GITLAB_DEPENDENCY_PROXY}ruby:3.0-alpine3.13 + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:3.0-alpine3.13 stage: prepare needs: [] before_script: diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml index 7f9edd1650a..e417b054cd6 100644 --- a/.gitlab/ci/setup.gitlab-ci.yml +++ b/.gitlab/ci/setup.gitlab-ci.yml @@ -30,7 +30,7 @@ cache gems: .absolutely-minimal-job: extends: - .minimal-job - image: ${GITLAB_DEPENDENCY_PROXY}alpine:edge + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}alpine:edge variables: GIT_STRATEGY: none @@ -79,7 +79,7 @@ verify-ruby-2.7: verify-tests-yml: extends: - .setup:rules:verify-tests-yml - image: ${GITLAB_DEPENDENCY_PROXY}ruby:${RUBY_VERSION}-alpine3.13 + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}-alpine3.13 stage: test needs: [] script: @@ -116,7 +116,7 @@ generate-frontend-fixtures-mapping: detect-tests: extends: .rails:rules:detect-tests - image: ${GITLAB_DEPENDENCY_PROXY}ruby:${RUBY_VERSION} + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION} needs: [] stage: prepare variables: diff --git a/.gitlab/ci/test-metadata.gitlab-ci.yml b/.gitlab/ci/test-metadata.gitlab-ci.yml index f4fa39300b6..e147305e25a 100644 --- a/.gitlab/ci/test-metadata.gitlab-ci.yml +++ b/.gitlab/ci/test-metadata.gitlab-ci.yml @@ -1,5 +1,5 @@ .tests-metadata-state: - image: ${GITLAB_DEPENDENCY_PROXY}ruby:${RUBY_VERSION} + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION} before_script: - source scripts/utils.sh artifacts: diff --git a/.gitlab/ci/workhorse.gitlab-ci.yml b/.gitlab/ci/workhorse.gitlab-ci.yml index 4ed674948cf..a11d5f000cf 100644 --- a/.gitlab/ci/workhorse.gitlab-ci.yml +++ b/.gitlab/ci/workhorse.gitlab-ci.yml @@ -1,6 +1,6 @@ workhorse:verify: extends: .workhorse:rules:workhorse - image: ${GITLAB_DEPENDENCY_PROXY}golang:${GO_VERSION} + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}golang:${GO_VERSION} stage: test needs: [] script: diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index 1b345a36c7e..b6db7f9d358 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -3,7 +3,7 @@ import { VueRenderer } from '@tiptap/vue-2'; import tippy from 'tippy.js'; import Suggestion from '@tiptap/suggestion'; import { PluginKey } from 'prosemirror-state'; -import { isFunction, uniqueId } from 'lodash'; +import { isFunction, uniqueId, memoize } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { initEmojiMap, getAllEmoji } from '~/emoji'; import SuggestionsDropdown from '../components/suggestions_dropdown.vue'; @@ -21,6 +21,10 @@ function createSuggestionPlugin({ nodeType, nodeProps = {}, }) { + const fetchData = memoize( + isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, + ); + return Suggestion({ editor, char, @@ -38,9 +42,7 @@ function createSuggestionPlugin({ if (!dataSource) return []; try { - const items = isFunction(dataSource) - ? await dataSource() - : (await axios.get(dataSource)).data; + const items = await fetchData(); return items.filter(search(query)).slice(0, limit); } catch { diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index cd7cb7f8393..a9af1181027 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -17,17 +17,11 @@ import { import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import { - VARIABLE_TYPE, - FILE_TYPE, - CONFIG_VARIABLES_TIMEOUT, - CC_VALIDATION_REQUIRED_ERROR, -} from '../constants'; +import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; +import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql'; +import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; import filterVariables from '../utils/filter_variables'; import RefsDropdown from './refs_dropdown.vue'; @@ -76,10 +70,6 @@ export default { type: String, required: true, }, - configVariablesPath: { - type: String, - required: true, - }, defaultBranch: { type: String, required: true, @@ -97,6 +87,10 @@ export default { required: false, default: () => ({}), }, + projectPath: { + type: String, + required: true, + }, refParam: { type: String, required: false, @@ -116,19 +110,77 @@ export default { return { refValue: { shortName: this.refParam, + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, }, form: {}, errorTitle: null, error: null, + predefinedValueOptions: {}, warnings: [], totalWarnings: 0, isWarningDismissed: false, - isLoading: false, submitted: false, ccAlertDismissed: false, }; }, + apollo: { + ciConfigVariables: { + query: ciConfigVariablesQuery, + // Skip when variables already cached in `form` + skip() { + return Object.keys(this.form).includes(this.refFullName); + }, + variables() { + return { + fullPath: this.projectPath, + ref: this.refQueryParam, + }; + }, + update({ project }) { + return project?.ciConfigVariables || []; + }, + result({ data }) { + const predefinedVars = data?.project?.ciConfigVariables || []; + const params = {}; + const descriptions = {}; + + predefinedVars.forEach(({ description, key, value, valueOptions }) => { + if (description) { + params[key] = value; + descriptions[key] = description; + this.predefinedValueOptions[key] = valueOptions; + } + }); + + Vue.set(this.form, this.refFullName, { descriptions, variables: [] }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }, + error(error) { + Sentry.captureException(error); + }, + }, + }, computed: { + isLoading() { + return this.$apollo.queries.ciConfigVariables.loading; + }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -147,6 +199,9 @@ export default { refFullName() { return this.refValue.fullName; }, + refQueryParam() { + return this.refFullName || this.refShortName; + }, variables() { return this.form[this.refFullName]?.variables ?? []; }, @@ -157,21 +212,6 @@ export default { return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; }, }, - watch: { - refValue() { - this.loadConfigVariablesForm(); - }, - }, - created() { - // this is needed until we add support for ref type in url query strings - // ensure default branch is called with full ref on load - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - if (this.refValue.shortName === this.defaultBranch) { - this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; - } - - this.loadConfigVariablesForm(); - }, methods: { addEmptyVariable(refValue) { const { variables } = this.form[refValue]; @@ -204,132 +244,57 @@ export default { }); } }, - setVariableType(key, type) { + setVariableAttribute(key, attribute, value) { const { variables } = this.form[this.refFullName]; const variable = variables.find((v) => v.key === key); - variable.variable_type = type; + variable[attribute] = value; }, setVariableParams(refValue, type, paramsObj) { Object.entries(paramsObj).forEach(([key, value]) => { this.setVariable(refValue, type, key, value); }); }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, removeVariable(index) { this.variables.splice(index, 1); }, canRemove(index) { return index < this.variables.length - 1; }, - loadConfigVariablesForm() { - // Skip when variables already cached in `form` - if (this.form[this.refFullName]) { - return; - } - - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - }, - fetchConfigVariables(refValue) { - this.isLoading = true; - - return backOff((next, stop) => { - axios - .get(this.configVariablesPath, { - params: { - sha: refValue, - }, - }) - .then(({ data, status }) => { - if (status === httpStatusCodes.NO_CONTENT) { - next(); - } else { - this.isLoading = false; - stop(data); - } - }) - .catch((error) => { - stop(error); - }); - }, CONFIG_VARIABLES_TIMEOUT) - .then((data) => { - const params = {}; - const descriptions = {}; - - Object.entries(data).forEach(([key, { value, description }]) => { - if (description) { - params[key] = value; - descriptions[key] = description; - } - }); - - return { params, descriptions }; - }) - .catch((error) => { - this.isLoading = false; - - Sentry.captureException(error); - - return { params: {}, descriptions: {} }; - }); - }, - createPipeline() { + async createPipeline() { this.submitted = true; this.ccAlertDismissed = false; - return axios - .post(this.pipelinesPath, { + const { data } = await this.$apollo.mutate({ + mutation: createPipelineMutation, + variables: { + endpoint: this.pipelinesPath, // send shortName as fall back for query params // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - ref: this.refValue.fullName || this.refShortName, - variables_attributes: filterVariables(this.variables), - }) - .then(({ data }) => { - redirectTo(`${this.pipelinesPath}/${data.id}`); - }) - .catch((err) => { - // always re-enable submit button - this.submitted = false; + ref: this.refQueryParam, + variablesAttributes: filterVariables(this.variables), + }, + }); - const { - errors = [], - warnings = [], - total_warnings: totalWarnings = 0, - } = err.response.data; - const [error] = errors; + const { id, errors, totalWarnings, warnings } = data.createPipeline; - this.reportError({ - title: i18n.submitErrorTitle, - error, - warnings, - totalWarnings, - }); - }); + if (id) { + redirectTo(`${this.pipelinesPath}/${id}`); + return; + } + + // always re-enable submit button + this.submitted = false; + const [error] = errors; + + this.reportError({ + title: i18n.submitErrorTitle, + error, + warnings, + totalWarnings, + }); }, onRefsLoadingError(error) { this.reportError({ title: i18n.refsLoadingErrorTitle }); @@ -416,7 +381,7 @@ export default { <gl-dropdown-item v-for="type in Object.keys($options.typeOptions)" :key="type" - @click="setVariableType(variable.key, type)" + @click="setVariableAttribute(variable.key, 'variable_type', type)" > {{ $options.typeOptions[type] }} </gl-dropdown-item> @@ -429,7 +394,24 @@ export default { data-qa-selector="ci_variable_key_field" @change="addEmptyVariable(refFullName)" /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> <gl-form-textarea + v-else v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3" diff --git a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql b/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql new file mode 100644 index 00000000000..a76e8f6b95b --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql @@ -0,0 +1,9 @@ +mutation createPipeline($endpoint: String, $ref: String, $variablesAttributes: Array) { + createPipeline(endpoint: $endpoint, ref: $ref, variablesAttributes: $variablesAttributes) + @client { + id + errors + totalWarnings + warnings + } +} diff --git a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql new file mode 100644 index 00000000000..648cd8b66b5 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql @@ -0,0 +1,11 @@ +query ciConfigVariables($fullPath: ID!, $ref: String!) { + project(fullPath: $fullPath) { + id + ciConfigVariables(sha: $ref) { + description + key + value + valueOptions + } + } +} diff --git a/app/assets/javascripts/pipeline_new/graphql/resolvers.js b/app/assets/javascripts/pipeline_new/graphql/resolvers.js new file mode 100644 index 00000000000..7b0f58e8cf9 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/resolvers.js @@ -0,0 +1,29 @@ +import axios from '~/lib/utils/axios_utils'; + +export const resolvers = { + Mutation: { + createPipeline: (_, { endpoint, ref, variablesAttributes }) => { + return axios + .post(endpoint, { ref, variables_attributes: variablesAttributes }) + .then((response) => { + const { id } = response.data; + return { + id, + errors: [], + totalWarnings: 0, + warnings: [], + }; + }) + .catch((err) => { + const { errors = [], totalWarnings = 0, warnings = [] } = err.response.data; + + return { + id: null, + errors, + totalWarnings, + warnings, + }; + }); + }, + }, +}; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index e3f363f4ada..60b4c93d1d5 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; +import { resolvers } from './graphql/resolvers'; const mountLegacyPipelineNewForm = (el) => { const { @@ -51,12 +54,12 @@ const mountPipelineNewForm = (el) => { projectRefsEndpoint, // props - configVariablesPath, defaultBranch, fileParam, maxWarnings, pipelinesPath, projectId, + projectPath, refParam, settingsLink, varParam, @@ -65,22 +68,27 @@ const mountPipelineNewForm = (el) => { const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); - // TODO: add apolloProvider + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); return new Vue({ el, + apolloProvider, provide: { projectRefsEndpoint, }, render(createElement) { return createElement(PipelineNewForm, { props: { - configVariablesPath, defaultBranch, fileParams, maxWarnings: Number(maxWarnings), pipelinesPath, projectId, + projectPath, refParam, settingsLink, variableParams, diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue index 3e49aeb808e..4a08a82275a 100644 --- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue @@ -1,16 +1,35 @@ <script> -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; export default { i18n: { schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'), + scheduleDeleteError: s__( + 'PipelineSchedules|There was a problem deleting the pipeline schedule.', + ), + }, + modal: { + id: 'delete-pipeline-schedule-modal', + deleteConfirmation: s__( + 'PipelineSchedules|Are you sure you want to delete this pipeline schedule?', + ), + actionPrimary: { + text: s__('PipelineSchedules|Delete pipeline schedule'), + attributes: [{ variant: 'danger' }], + }, + actionCancel: { + text: __('Cancel'), + attributes: [], + }, }, components: { GlAlert, GlLoadingIcon, + GlModal, PipelineSchedulesTable, }, inject: { @@ -30,7 +49,7 @@ export default { return project?.pipelineSchedules?.nodes || []; }, error() { - this.hasError = true; + this.reportError(this.$options.i18n.schedulesFetchError); }, }, }, @@ -38,15 +57,48 @@ export default { return { schedules: [], hasError: false, - errorDismissed: false, + errorMessage: '', + scheduleToDeleteId: null, + showModal: false, }; }, computed: { isLoading() { return this.$apollo.queries.schedules.loading; }, - showError() { - return this.hasError && !this.errorDismissed; + }, + methods: { + reportError(error) { + this.hasError = true; + this.errorMessage = error; + }, + showDeleteModal(id) { + this.showModal = true; + this.scheduleToDeleteId = id; + }, + hideModal() { + this.showModal = false; + this.scheduleToDeleteId = null; + }, + async deleteSchedule() { + try { + const { + data: { + pipelineScheduleDelete: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineScheduleMutation, + variables: { id: this.scheduleToDeleteId }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.$apollo.queries.schedules.refetch(); + } + } catch { + this.reportError(this.$options.i18n.scheduleDeleteError); + } }, }, }; @@ -54,14 +106,29 @@ export default { <template> <div> - <gl-alert v-if="showError" class="gl-mb-2" variant="danger" @dismiss="errorDismissed = true"> - {{ $options.i18n.schedulesFetchError }} + <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false"> + {{ errorMessage }} </gl-alert> <gl-loading-icon v-if="isLoading" size="lg" /> <!-- Tabs will be addressed in #371989 --> - <pipeline-schedules-table v-else :schedules="schedules" /> + <template v-else> + <pipeline-schedules-table :schedules="schedules" @showDeleteModal="showDeleteModal" /> + + <gl-modal + :visible="showModal" + :title="$options.modal.actionPrimary.text" + :modal-id="$options.modal.id" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + size="sm" + @primary="deleteSchedule" + @hide="hideModal" + > + {{ $options.modal.deleteConfirmation }} + </gl-modal> + </template> </div> </template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index c49220c0d68..76d118bf52d 100644 --- a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -58,6 +58,8 @@ export default { :title="$options.i18n.deleteTooltip" icon="remove" variant="danger" + data-testid="delete-pipeline-schedule-btn" + @click="$emit('showDeleteModal', schedule.id)" /> </gl-button-group> </div> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue index 00fe65cb9af..d54008b81b2 100644 --- a/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue +++ b/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -86,7 +86,10 @@ export default { </template> <template #cell(actions)="{ item }"> - <pipeline-schedule-actions :schedule="item" /> + <pipeline-schedule-actions + :schedule="item" + @showDeleteModal="$emit('showDeleteModal', $event)" + /> </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql b/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..8aab0b3fbde --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation deletePipelineSchedule($id: CiPipelineScheduleID!) { + pipelineScheduleDelete(input: { id: $id }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 32d2e91903d..8821084ef35 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -346,6 +346,11 @@ export default { :suggestions-list-class="suggestionsListClass" :search-button-attributes="searchButtonAttributes" :search-input-attributes="searchInputAttributes" + :recent-searches-header="__('Recent searches')" + :clear-button-title="__('Clear')" + :close-button-title="__('Close')" + :clear-recent-searches-text="__('Clear recent searches')" + :no-recent-searches-text="__(`You don't have any recent searches`)" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear="onClear" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 77f590ad3e0..827ec64f98a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -294,7 +294,7 @@ export default { <template v-else> <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> - <p class="gl-mt-3 gl-mb-4"> + <p class="gl-mb-3"> {{ $options.i18n.emptyStateMessage }} </p> </div> diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb index b0cffa2c088..27b066ffcf6 100644 --- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb @@ -19,7 +19,13 @@ module Mutations argument :job_token_scope_enabled, GraphQL::Types::Boolean, required: false, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.' + description: 'Indicates CI/CD job tokens generated in this project ' \ + 'have restricted access to other projects.' + + argument :inbound_job_token_scope_enabled, GraphQL::Types::Boolean, + required: false, + description: 'Indicates CI/CD job tokens generated in other projects ' \ + 'have restricted access to this project.' field :ci_cd_settings, Types::Ci::CiCdSettingType, @@ -28,6 +34,9 @@ module Mutations def resolve(full_path:, **args) project = authorized_find!(full_path) + + args.delete(:inbound_job_token_scope_enabled) unless Feature.enabled?(:ci_inbound_job_token_scope, project) + settings = project.ci_cd_settings settings.update(args) diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index bec8c72e783..574791b79e6 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -10,8 +10,17 @@ module Types field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.', + description: 'Indicates CI/CD job tokens generated in this project ' \ + 'have restricted access to other projects.', method: :job_token_scope_enabled? + + field :inbound_job_token_scope_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Indicates CI/CD job tokens generated in other projects ' \ + 'have restricted access to this project.', + method: :inbound_job_token_scope_enabled? + field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, description: 'Whether to keep the latest builds artifacts.', method: :keep_latest_artifacts_available? diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index e05f7dcbf33..866399f3021 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -72,8 +72,10 @@ module MarkupHelper tags = %w(a gl-emoji b strong i em pre code p span) tags << 'img' if options[:allow_images] - text = truncate_visible(md, max_chars || md.length) - text = prepare_for_rendering(text, markdown_field_render_context(object, attribute, options)) + context = markdown_field_render_context(object, attribute, options) + context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length) + + text = prepare_for_rendering(md, context) text = sanitize( text, tags: tags, @@ -191,55 +193,6 @@ module MarkupHelper { project: wiki.container } end - # Return +text+, truncated to +max_chars+ characters, excluding any HTML - # tags. - def truncate_visible(text, max_chars) - doc = Nokogiri::HTML.fragment(text) - content_length = 0 - truncated = false - - doc.traverse do |node| - if node.text? || node.content.empty? - if truncated - node.remove - next - end - - # Handle line breaks within a node - if node.content.strip.lines.length > 1 - node.content = "#{node.content.lines.first.chomp}..." - truncated = true - end - - num_remaining = max_chars - content_length - if node.content.length > num_remaining - node.content = node.content.truncate(num_remaining) - truncated = true - end - - content_length += node.content.length - end - - truncated = truncate_if_block(node, truncated) - end - - doc.to_html - end - - # Used by #truncate_visible. If +node+ is the first block element, and the - # text hasn't already been truncated, then append "..." to the node contents - # and return true. Otherwise return false. - def truncate_if_block(node, truncated) - return true if truncated - - if node.element? && (node.description&.block? || node.matches?('pre > code > .line')) - node.inner_html = "#{node.inner_html}..." if node.next_sibling - true - else - truncated - end - end - def strip_empty_link_tags(text) scrubber = Loofah::Scrubber.new do |node| node.remove if node.name == 'a' && node.children.empty? diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb index 2e8532fa739..b4e3d6874ef 100644 --- a/app/models/members/member_role.rb +++ b/app/models/members/member_role.rb @@ -4,6 +4,15 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass has_many :members belongs_to :namespace - validates :namespace_id, presence: true + validates :namespace, presence: true validates :base_access_level, presence: true + validate :belongs_to_top_level_namespace + + private + + def belongs_to_top_level_namespace + return if !namespace || namespace.root? + + errors.add(:namespace, s_("must be top-level namespace")) + end end diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index a4144f8ab0d..d2b2a58fcf8 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -12,6 +12,7 @@ ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, + project_path: @project.full_path, project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'), settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } diff --git a/config/feature_flags/development/ci_inbound_job_token_scope.yml b/config/feature_flags/development/ci_inbound_job_token_scope.yml new file mode 100644 index 00000000000..0a7a618531c --- /dev/null +++ b/config/feature_flags/development/ci_inbound_job_token_scope.yml @@ -0,0 +1,8 @@ +--- +name: ci_inbound_job_token_scope +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/99165 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/376063 +milestone: '15.5' +type: development +group: group::pipeline execution +default_enabled: false diff --git a/db/post_migrate/20221006172302_adjust_task_note_rename_background_migration_values.rb b/db/post_migrate/20221006172302_adjust_task_note_rename_background_migration_values.rb new file mode 100644 index 00000000000..2af16fb6d3c --- /dev/null +++ b/db/post_migrate/20221006172302_adjust_task_note_rename_background_migration_values.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class AdjustTaskNoteRenameBackgroundMigrationValues < Gitlab::Database::Migration[2.0] + restrict_gitlab_migration gitlab_schema: :gitlab_main + + JOB_CLASS_NAME = 'RenameTaskSystemNoteToChecklistItem' + MIGRATION_FAILED_STATUS = 4 + MIGRATION_FINISHED_STATUS = 3 + MIGRATION_ACTIVE_STATUS = 1 + JOB_FAILED_STATUS = 2 + + OLD_BATCH_SIZE = 10_000 + NEW_BATCH_SIZE = 5_000 + + OLD_SUB_BATCH_SIZE = 100 + NEW_SUB_BATCH_SIZE = 10 + + class InlineBatchedMigration < MigrationRecord + self.table_name = :batched_background_migrations + + scope :for_configuration, ->(job_class_name, table_name, column_name, job_arguments) do + where(job_class_name: job_class_name, table_name: table_name, column_name: column_name) + .where("job_arguments = ?", job_arguments.to_json) # rubocop:disable Rails/WhereEquals + end + end + + class InlineBatchedJob < MigrationRecord + include EachBatch + self.table_name = :batched_background_migration_jobs + end + + def up + migration = InlineBatchedMigration.for_configuration( + JOB_CLASS_NAME, + :system_note_metadata, + :id, + [] + ).first + return if migration.blank? || migration.status == MIGRATION_FINISHED_STATUS + + InlineBatchedJob.where( + batched_background_migration_id: migration.id, + status: JOB_FAILED_STATUS + ).each_batch(of: 100) do |batch| + batch.update_all(attempts: 0, sub_batch_size: NEW_SUB_BATCH_SIZE) + end + + update_params = { batch_size: NEW_BATCH_SIZE, sub_batch_size: NEW_SUB_BATCH_SIZE } + + if migration.status == MIGRATION_FAILED_STATUS + update_params[:status] = MIGRATION_ACTIVE_STATUS + update_params[:started_at] = Time.zone.now if migration.respond_to?(:started_at) + end + + migration.update!(**update_params) + end + + def down + migration = InlineBatchedMigration.for_configuration( + JOB_CLASS_NAME, + :system_note_metadata, + :id, + [] + ).first + return if migration.blank? + + migration.update!( + batch_size: OLD_BATCH_SIZE, + sub_batch_size: OLD_SUB_BATCH_SIZE + ) + end +end diff --git a/db/schema_migrations/20221006172302 b/db/schema_migrations/20221006172302 new file mode 100644 index 00000000000..361fdd78086 --- /dev/null +++ b/db/schema_migrations/20221006172302 @@ -0,0 +1 @@ +19e5ca6f9716fd41bfe8a103dab8a1dc37107f99503abedbdcb8175b699283f2
\ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 67dea28cbd5..9fb38f55598 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1028,7 +1028,8 @@ Input type: `CiCdSettingsUpdateInput` | ---- | ---- | ----------- | | <a id="mutationcicdsettingsupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationcicdsettingsupdatefullpath"></a>`fullPath` | [`ID!`](#id) | Full Path of the project the settings belong to. | -| <a id="mutationcicdsettingsupdatejobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI job tokens generated in this project have restricted access to resources. | +| <a id="mutationcicdsettingsupdateinboundjobtokenscopeenabled"></a>`inboundJobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in other projects have restricted access to this project. | +| <a id="mutationcicdsettingsupdatejobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in this project have restricted access to other projects. | | <a id="mutationcicdsettingsupdatekeeplatestartifact"></a>`keepLatestArtifact` | [`Boolean`](#boolean) | Indicates if the latest artifact should be kept for this project. | | <a id="mutationcicdsettingsupdatemergepipelinesenabled"></a>`mergePipelinesEnabled` | [`Boolean`](#boolean) | Indicates if merge pipelines are enabled for the project. | | <a id="mutationcicdsettingsupdatemergetrainsenabled"></a>`mergeTrainsEnabled` | [`Boolean`](#boolean) | Indicates if merge trains are enabled for the project. | @@ -4187,7 +4188,8 @@ Input type: `ProjectCiCdSettingsUpdateInput` | ---- | ---- | ----------- | | <a id="mutationprojectcicdsettingsupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationprojectcicdsettingsupdatefullpath"></a>`fullPath` | [`ID!`](#id) | Full Path of the project the settings belong to. | -| <a id="mutationprojectcicdsettingsupdatejobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI job tokens generated in this project have restricted access to resources. | +| <a id="mutationprojectcicdsettingsupdateinboundjobtokenscopeenabled"></a>`inboundJobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in other projects have restricted access to this project. | +| <a id="mutationprojectcicdsettingsupdatejobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in this project have restricted access to other projects. | | <a id="mutationprojectcicdsettingsupdatekeeplatestartifact"></a>`keepLatestArtifact` | [`Boolean`](#boolean) | Indicates if the latest artifact should be kept for this project. | | <a id="mutationprojectcicdsettingsupdatemergepipelinesenabled"></a>`mergePipelinesEnabled` | [`Boolean`](#boolean) | Indicates if merge pipelines are enabled for the project. | | <a id="mutationprojectcicdsettingsupdatemergetrainsenabled"></a>`mergeTrainsEnabled` | [`Boolean`](#boolean) | Indicates if merge trains are enabled for the project. | @@ -17270,7 +17272,8 @@ four standard [pagination arguments](#connection-pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="projectcicdsettingjobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI job tokens generated in this project have restricted access to resources. | +| <a id="projectcicdsettinginboundjobtokenscopeenabled"></a>`inboundJobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in other projects have restricted access to this project. | +| <a id="projectcicdsettingjobtokenscopeenabled"></a>`jobTokenScopeEnabled` | [`Boolean`](#boolean) | Indicates CI/CD job tokens generated in this project have restricted access to other projects. | | <a id="projectcicdsettingkeeplatestartifact"></a>`keepLatestArtifact` | [`Boolean`](#boolean) | Whether to keep the latest builds artifacts. | | <a id="projectcicdsettingmergepipelinesenabled"></a>`mergePipelinesEnabled` | [`Boolean`](#boolean) | Whether merge pipelines are enabled. | | <a id="projectcicdsettingmergetrainsenabled"></a>`mergeTrainsEnabled` | [`Boolean`](#boolean) | Whether merge trains are enabled. | diff --git a/doc/ci/pipelines/cicd_minutes.md b/doc/ci/pipelines/cicd_minutes.md index 910e6600672..14a8ad7f59a 100644 --- a/doc/ci/pipelines/cicd_minutes.md +++ b/doc/ci/pipelines/cicd_minutes.md @@ -139,6 +139,8 @@ Premium license: If you use `13,000` minutes during the month, the next month your additional minutes become `2,000`. If you use `9,000` minutes during the month, your additional minutes remain the same. +If you bought additional CI/CD minutes while on a trial subscription those minutes will be available after the trial ends or you upgrade to a paid plan. + You can find pricing for additional CI/CD minutes on the [GitLab Pricing page](https://about.gitlab.com/pricing/). diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md index 130fa17c73f..debb33a2854 100644 --- a/doc/development/pipelines.md +++ b/doc/development/pipelines.md @@ -587,8 +587,9 @@ The current stages are: ### Dependency Proxy Some of the jobs are using images from Docker Hub, where we also use -`${GITLAB_DEPENDENCY_PROXY}` as a prefix to the image path, so that we pull +`${GITLAB_DEPENDENCY_PROXY_ADDRESS}` as a prefix to the image path, so that we pull images from our [Dependency Proxy](../user/packages/dependency_proxy/index.md). +By default, this variable is set from the value of `${GITLAB_DEPENDENCY_PROXY}`. `${GITLAB_DEPENDENCY_PROXY}` is a group CI/CD variable defined in [`gitlab-org`](https://gitlab.com/gitlab-org) as @@ -596,13 +597,32 @@ images from our [Dependency Proxy](../user/packages/dependency_proxy/index.md). defined as: ```yaml -image: ${GITLAB_DEPENDENCY_PROXY}alpine:edge +image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}alpine:edge ``` Projects in the `gitlab-org` group pull from the Dependency Proxy, while forks that reside on any other personal namespaces or groups fall back to Docker Hub unless `${GITLAB_DEPENDENCY_PROXY}` is also defined there. +#### Work around for when a pipeline is started by a Project access token user + +When a pipeline is started by a Project access token user (e.g. the `release-tools approver bot` user which +automatically updates the Gitaly version used in the main project), +[the Dependency proxy isn't accessible](https://gitlab.com/gitlab-org/gitlab/-/issues/332411#note_1130388163) +and the job fails at the `Preparing the "docker+machine" executor` step. +To work around that, we have a special workflow rule, that overrides the +`${GITLAB_DEPENDENCY_PROXY_ADDRESS}` variable so that Depdendency proxy isn't used in that case: + +```yaml +- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $GITLAB_USER_LOGIN =~ /project_\d+_bot\d*/' + variables: + GITLAB_DEPENDENCY_PROXY_ADDRESS: "" +``` + +NOTE: +We don't directly override the `${GITLAB_DEPENDENCY_PROXY}` variable because group-level +variables have higher precedence over `.gitlab-ci.yml` variables. + ### Common job definitions Most of the jobs [extend from a few CI definitions](../ci/yaml/index.md#extends) diff --git a/doc/user/clusters/agent/work_with_agent.md b/doc/user/clusters/agent/work_with_agent.md index b0a13e4998e..566eae8e24e 100644 --- a/doc/user/clusters/agent/work_with_agent.md +++ b/doc/user/clusters/agent/work_with_agent.md @@ -67,13 +67,13 @@ The agent has two loggers: - A general purpose logger, which defaults to `info`. - A gRPC logger, which defaults to `error`. -One can change their log levels by using a top-level `observability` section in the [agent configuration file](install/index.md#configure-your-agent), for example setting the levels to `debug` and `warning`: +You can change your log levels by using a top-level `observability` section in the [agent configuration file](install/index.md#configure-your-agent), for example setting the levels to `debug` and `warn`: ```yaml observability: logging: level: debug - grpc_level: warning + grpc_level: warn ``` When `grpc_level` is set to `info` or below, there will be a lot of gRPC logs. diff --git a/lib/banzai/filter/truncate_visible_filter.rb b/lib/banzai/filter/truncate_visible_filter.rb new file mode 100644 index 00000000000..edd6efd4706 --- /dev/null +++ b/lib/banzai/filter/truncate_visible_filter.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Banzai + module Filter + class TruncateVisibleFilter < HTML::Pipeline::Filter + # Truncates the document to `truncate_visible_max_chars` characters, + # excluding any HTML tags. + + MATCH_CODE = 'pre > code > .line' + + def call + return doc unless context[:truncate_visible_max_chars].present? + + max_chars = context[:truncate_visible_max_chars] + content_length = 0 + @truncated = false + + doc.traverse do |node| + if node.text? || node.content.empty? + if truncated + node.remove + next + end + + handle_line_breaks(node) + truncate_content(content_length, max_chars, node) + + content_length += node.content.length + end + + truncate_if_block(node) + end + + doc + end + + private + + attr_reader :truncated + + def truncate_content(content_length, max_chars, node) + num_remaining = max_chars - content_length + return unless node.content.length > num_remaining + + node.content = node.content.truncate(num_remaining) + @truncated = true + end + + # Handle line breaks within a node + def handle_line_breaks(node) + return unless node.content.strip.lines.length > 1 + + node.content = "#{node.content.lines.first.chomp}..." + @truncated = true + end + + # If `node` is the first block element, and the + # text hasn't already been truncated, then append "..." to the node contents + # and return true. Otherwise return false. + def truncate_if_block(node) + return if truncated + return unless node.element? && (node.description&.block? || node.matches?(MATCH_CODE)) + + node.inner_html = "#{node.inner_html}..." if node.next_sibling + @truncated = true + end + end + end +end diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb index da2262cdf83..f8035698b9b 100644 --- a/lib/banzai/pipeline/post_process_pipeline.rb +++ b/lib/banzai/pipeline/post_process_pipeline.rb @@ -5,6 +5,7 @@ module Banzai class PostProcessPipeline < BasePipeline def self.filters @filters ||= FilterArray[ + Filter::TruncateVisibleFilter, *internal_link_filters, Filter::AbsoluteLinkFilter, Filter::BroadcastMessagePlaceholdersFilter diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 268a76dd76a..5271fca02ff 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -29511,6 +29511,9 @@ msgstr "" msgid "PipelineSchedules|All" msgstr "" +msgid "PipelineSchedules|Are you sure you want to delete this pipeline schedule?" +msgstr "" + msgid "PipelineSchedules|Delete pipeline schedule" msgstr "" @@ -29553,6 +29556,9 @@ msgstr "" msgid "PipelineSchedules|Target" msgstr "" +msgid "PipelineSchedules|There was a problem deleting the pipeline schedule." +msgstr "" + msgid "PipelineSchedules|There was a problem fetching pipeline schedules." msgstr "" @@ -48321,6 +48327,9 @@ msgstr "" msgid "must be set for a project namespace" msgstr "" +msgid "must be top-level namespace" +msgstr "" + msgid "must be unique by status and elapsed time within a policy" msgstr "" diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 5ce29bd6c5d..3e699b93fd3 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,72 +1,101 @@ -import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; +import ciConfigVariablesQuery from '~/pipeline_new/graphql/queries/ci_config_variables.graphql'; +import { resolvers } from '~/pipeline_new/graphql/resolvers'; import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; import { + mockCreditCardValidationRequiredError, + mockCiConfigVariablesResponse, + mockCiConfigVariablesResponseWithoutDesc, + mockEmptyCiConfigVariablesResponse, + mockError, mockQueryParams, mockPostParams, mockProjectId, - mockError, mockRefs, - mockCreditCardValidationRequiredError, + mockYamlVariables, } from '../mock_data'; +Vue.use(VueApollo); + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; -const configVariablesPath = '/root/project/-/pipelines/config_variables'; +const projectPath = '/root/project/-/pipelines/config_variables'; const newPipelinePostResponse = { id: 1 }; const defaultBranch = 'main'; describe('Pipeline New Form', () => { let wrapper; let mock; + let mockApollo; + let mockCiConfigVariables; let dummySubmitEvent; const findForm = () => wrapper.findComponent(GlForm); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); - const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); - const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); - const findDropdowns = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-type"]'); - const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); - const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); - const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); - const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); + const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); + const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findValueDropdowns = () => + wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); + const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); + const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert'); + const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert'); const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); - const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); - const selectBranch = (branch) => { + const selectBranch = async (branch) => { // Select a branch in the dropdown findRefsDropdown().vm.$emit('input', { shortName: branch, fullName: `refs/heads/${branch}`, }); + + await waitForPromises(); + }; + + const changeKeyInputValue = async (keyInputIndex, value) => { + const input = findKeyInputs().at(keyInputIndex); + input.element.value = value; + input.trigger('change'); + + await nextTick(); }; - const createComponent = (props = {}, method = shallowMount) => { + const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => { + const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; + mockApollo = createMockApollo(handlers, resolvers); + wrapper = method(PipelineNewForm, { + apolloProvider: mockApollo, provide: { projectRefsEndpoint, }, propsData: { projectId: mockProjectId, pipelinesPath, - configVariablesPath, + projectPath, defaultBranch, refParam: defaultBranch, settingsLink: '', @@ -78,7 +107,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mockCiConfigVariables = jest.fn(); mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); dummySubmitEvent = { @@ -87,24 +116,20 @@ describe('Pipeline New Form', () => { }); afterEach(() => { - wrapper.destroy(); - wrapper = null; - mock.restore(); + wrapper.destroy(); }); describe('Form', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays the correct values for the provided query params', async () => { - expect(findDropdowns().at(0).props('text')).toBe('Variable'); - expect(findDropdowns().at(1).props('text')).toBe('File'); + expect(findVariableTypes().at(0).props('text')).toBe('Variable'); + expect(findVariableTypes().at(1).props('text')).toBe('File'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); expect(findVariableRows()).toHaveLength(3); }); @@ -117,7 +142,7 @@ describe('Pipeline New Form', () => { it('displays an empty variable for the user to fill out', async () => { expect(findKeyInputs().at(2).element.value).toBe(''); expect(findValueInputs().at(2).element.value).toBe(''); - expect(findDropdowns().at(2).props('text')).toBe('Variable'); + expect(findVariableTypes().at(2).props('text')).toBe('Variable'); }); it('does not display remove icon for last row', () => { @@ -147,13 +172,12 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - - await waitForPromises(); }); it('does not submit the native HTML form', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); @@ -161,7 +185,7 @@ describe('Pipeline New Form', () => { }); it('disables the submit button immediately after submitting', async () => { - createComponent(); + createComponentWithApollo(); expect(findSubmitButton().props('disabled')).toBe(false); @@ -172,7 +196,7 @@ describe('Pipeline New Form', () => { }); it('creates pipeline with full ref and variables', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); await waitForPromises(); @@ -182,7 +206,7 @@ describe('Pipeline New Form', () => { }); it('creates a pipeline with short ref and variables from the query params', async () => { - createComponent(mockQueryParams); + createComponentWithApollo({ props: mockQueryParams }); await waitForPromises(); @@ -197,64 +221,51 @@ describe('Pipeline New Form', () => { describe('When the ref has been changed', () => { beforeEach(async () => { - createComponent({}, mount); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('variables persist between ref changes', async () => { - selectBranch('main'); - - await waitForPromises(); - const mainInput = findKeyInputs().at(0); - mainInput.element.value = 'build_var'; - mainInput.trigger('change'); + it('variables persist between ref changes', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await nextTick(); + await selectBranch('branch-1'); + await changeKeyInputValue(0, 'deploy_var'); - selectBranch('branch-1'); + await selectBranch('main'); - await waitForPromises(); + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); - const branchOneInput = findKeyInputs().at(0); - branchOneInput.element.value = 'deploy_var'; - branchOneInput.trigger('change'); + await selectBranch('branch-1'); - await nextTick(); + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); + }); - selectBranch('main'); + it('skips query call when form variables are already cached', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await waitForPromises(); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); - expect(findKeyInputs().at(0).element.value).toBe('build_var'); - expect(findVariableRows().length).toBe(2); + await selectBranch('branch-1'); - selectBranch('branch-1'); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); - await waitForPromises(); + // no additional call since `main` form values have been cached + await selectBranch('main'); - expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); - expect(findVariableRows().length).toBe(2); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); }); }); describe('when yml defines a variable', () => { - const mockYmlKey = 'yml_var'; - const mockYmlValue = 'yml_var_val'; - const mockYmlMultiLineValue = `A value - with multiple - lines`; - const mockYmlDesc = 'A var from yml.'; - it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); expect(findLoadingIcon().exists()).toBe(true); @@ -263,51 +274,62 @@ describe('Pipeline New Form', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent(mockQueryParams, mount); + describe('with different predefined values', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + + it('multi-line strings are added to the value field without removing line breaks', () => { + expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value); + }); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlMultiLineValue, - description: mockYmlDesc, - }, + it('multiple predefined values are rendered as a dropdown', () => { + const dropdown = findValueDropdowns().at(0); + const dropdownItems = findValueDropdownItems(dropdown); + const { valueOptions } = mockYamlVariables[2]; + + expect(dropdownItems.at(0).text()).toBe(valueOptions[0]); + expect(dropdownItems.at(1).text()).toBe(valueOptions[1]); + expect(dropdownItems.at(2).text()).toBe(valueOptions[2]); }); - await waitForPromises(); + it('variables with multiple predefined values sets the first option as the default', () => { + const dropdown = findValueDropdowns().at(0); + const { valueOptions } = mockYamlVariables[2]; - expect(findValueInputs().at(0).element.value).toBe(mockYmlMultiLineValue); + expect(dropdown.props('text')).toBe(valueOptions[0]); + }); }); describe('with description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(4); + expect(findVariableRows()).toHaveLength(6); }); it('displays a variable from yml', () => { - expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); - expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key); + expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value); }); it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(1).element.value).toBe('test_var'); - expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + expect(findKeyInputs().at(3).element.value).toBe( + Object.keys(mockQueryParams.variableParams)[0], + ); + expect(findValueInputs().at(3).element.value).toBe( + Object.values(mockQueryParams.fileParams)[0], + ); }); it('adds a description to the first variable from yml', () => { - expect(findVariableRows().at(0).text()).toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).toContain(mockYamlVariables[0].description); }); it('removes the description when a variable key changes', async () => { @@ -316,39 +338,27 @@ describe('Pipeline New Form', () => { await nextTick(); - expect(findVariableRows().at(0).text()).not.toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).not.toContain(mockYamlVariables[0].description); }); }); describe('without description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: null, - }, - yml_var2: { - value: 'yml_var2_val', - }, - yml_var3: { - description: '', - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(3); + it('displays variables with description only', async () => { + expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end }); }); }); describe('Form errors and warnings', () => { beforeEach(() => { - createComponent(); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo(); }); describe('when the refs cannot be loaded', () => { diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index e99684ff417..e95a65171fc 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -65,3 +65,62 @@ export const mockVariables = [ }, { uniqueId: 'var-refs/heads/main4', variable_type: 'env_var', key: '', value: '' }, ]; + +export const mockYamlVariables = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: 'This is a variable with a multi-line value.', + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: 'This is a variable with predefined values.', + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockYamlVariablesWithoutDesc = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockCiConfigVariablesQueryResponse = (ciConfigVariables) => ({ + data: { + project: { + id: 1, + ciConfigVariables, + }, + }, +}); + +export const mockCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(mockYamlVariables); +export const mockEmptyCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse([]); +export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQueryResponse( + mockYamlVariablesWithoutDesc, +); diff --git a/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js index d0292c65bcd..cce8f480928 100644 --- a/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,13 +1,18 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineSchedules from '~/pipeline_schedules/components/pipeline_schedules.vue'; import PipelineSchedulesTable from '~/pipeline_schedules/components/table/pipeline_schedules_table.vue'; +import deletePipelineScheduleMutation from '~/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; import getPipelineSchedulesQuery from '~/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; -import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes } from '../mock_data'; +import { + mockGetPipelineSchedulesGraphQLResponse, + mockPipelineScheduleNodes, + deleteMutationResponse, +} from '../mock_data'; Vue.use(VueApollo); @@ -17,24 +22,28 @@ describe('Pipeline schedules app', () => { const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); - const createMockApolloProvider = (handler) => { - const requestHandlers = [[getPipelineSchedulesQuery, handler]]; + const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); + const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const createMockApolloProvider = ( + requestHandlers = [[getPipelineSchedulesQuery, successHandler]], + ) => { return createMockApollo(requestHandlers); }; - const createComponent = (handler = successHandler) => { + const createComponent = (requestHandlers) => { wrapper = shallowMount(PipelineSchedules, { provide: { fullPath: 'gitlab-org/gitlab', }, - apolloProvider: createMockApolloProvider(handler), + apolloProvider: createMockApolloProvider(requestHandlers), }); }; const findTable = () => wrapper.findComponent(PipelineSchedulesTable); const findAlert = () => wrapper.findComponent(GlAlert); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); afterEach(() => { wrapper.destroy(); @@ -69,11 +78,84 @@ describe('Pipeline schedules app', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('shows error alert', async () => { - createComponent(failedHandler); + it('shows query error alert', async () => { + createComponent([[getPipelineSchedulesQuery, failedHandler]]); await waitForPromises(); - expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('There was a problem fetching pipeline schedules.'); + }); + + it('shows delete mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem deleting the pipeline schedule.'); + }); + + it('deletes pipeline schedule and refetches query', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + ]); + + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('showDeleteModal', scheduleId); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + }); + + it('modal should be visible after event', async () => { + createComponent(); + + await waitForPromises(); + + expect(findModal().props('visible')).toBe(false); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + }); + + it('modal should be hidden', async () => { + createComponent(); + + await waitForPromises(); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + + findModal().vm.$emit('hide'); + + await nextTick(); + + expect(findModal().props('visible')).toBe(false); }); }); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index 8f51269f8ab..ecc1bdeb679 100644 --- a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -1,5 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineScheduleActions from '~/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; import { mockPipelineScheduleNodes, mockPipelineScheduleAsGuestNodes } from '../../../mock_data'; @@ -11,7 +11,7 @@ describe('Pipeline schedule actions', () => { }; const createComponent = (props = defaultProps) => { - wrapper = shallowMount(PipelineScheduleActions, { + wrapper = shallowMountExtended(PipelineScheduleActions, { propsData: { ...props, }, @@ -19,6 +19,7 @@ describe('Pipeline schedule actions', () => { }; const findAllButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); afterEach(() => { wrapper.destroy(); @@ -35,4 +36,14 @@ describe('Pipeline schedule actions', () => { expect(findAllButtons()).toHaveLength(0); }); + + it('delete button emits showDeleteModal event and schedule id', () => { + createComponent(); + + findDeleteBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + showDeleteModal: [[mockPipelineScheduleNodes[0].id]], + }); + }); }); diff --git a/spec/frontend/pipeline_schedules/mock_data.js b/spec/frontend/pipeline_schedules/mock_data.js index b551b4c529d..0a60998d8fb 100644 --- a/spec/frontend/pipeline_schedules/mock_data.js +++ b/spec/frontend/pipeline_schedules/mock_data.js @@ -1,3 +1,4 @@ +// Fixture located at spec/frontend/fixtures/pipeline_schedules.rb import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; @@ -21,4 +22,14 @@ export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleAsGuestNodes = guestNodes; +export const deleteMutationResponse = { + data: { + pipelineScheduleDelete: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleDeletePayload', + }, + }, +}; + export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 5b16a8fbb40..a2e34471324 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -562,20 +562,6 @@ FooBar shared_examples_for 'common markdown examples' do let(:project_base) { build(:project, :repository) } - it 'displays inline code' do - object = create_object('Text with `inline code`') - expected = 'Text with <code>inline code</code>' - - expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) - end - - it 'truncates the text with multiple paragraphs' do - object = create_object("Paragraph 1\n\nParagraph 2") - expected = 'Paragraph 1...' - - expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) - end - it 'displays the first line of a code block' do object = create_object("```\nCode block\nwith two lines\n```") expected = %r{<pre.+><code><span class="line">Code block\.\.\.</span>\n</code></pre>} @@ -591,18 +577,6 @@ FooBar expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected) end - it 'preserves a link href when link text is truncated' do - text = 'The quick brown fox jumped over the lazy dog' # 44 chars - link_url = 'http://example.com/foo/bar/baz' # 30 chars - input = "#{text}#{text}#{text} #{link_url}" # 163 chars - expected_link_text = 'http://example...</a>' - - object = create_object(input) - - expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(link_url) - expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected_link_text) - end - it 'preserves code color scheme' do object = create_object("```ruby\ndef test\n 'hello world'\nend\n```") expected = "\n<pre class=\"code highlight js-syntax-highlight language-ruby\">" \ @@ -669,40 +643,6 @@ FooBar expect(result).to include(html) end - it 'truncates Markdown properly' do - object = create_object("@#{user.username}, can you look at this?\nHello world\n") - actual = first_line_in_markdown(object, attribute, 100, project: project) - - doc = Nokogiri::HTML.parse(actual) - - # Make sure we didn't create invalid markup - expect(doc.errors).to be_empty - - # Leading user link - expect(doc.css('a').length).to eq(1) - expect(doc.css('a')[0].attr('href')).to eq user_path(user) - expect(doc.css('a')[0].text).to eq "@#{user.username}" - - expect(doc.content).to eq "@#{user.username}, can you look at this?..." - end - - it 'truncates Markdown with emoji properly' do - object = create_object("foo :wink:\nbar :grinning:") - actual = first_line_in_markdown(object, attribute, 100, project: project) - - doc = Nokogiri::HTML.parse(actual) - - # Make sure we didn't create invalid markup - # But also account for the 2 errors caused by the unknown `gl-emoji` elements - expect(doc.errors.length).to eq(2) - - expect(doc.css('gl-emoji').length).to eq(2) - expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' - expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' - - expect(doc.content).to eq "foo 😉\nbar 😀" - end - it 'does not post-process truncated text', :request_store do object = create_object("hello \n\n [Test](README.md)") diff --git a/spec/lib/banzai/filter/truncate_visible_filter_spec.rb b/spec/lib/banzai/filter/truncate_visible_filter_spec.rb new file mode 100644 index 00000000000..8daaed05264 --- /dev/null +++ b/spec/lib/banzai/filter/truncate_visible_filter_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Banzai::Filter::TruncateVisibleFilter do + include FilterSpecHelper + + let_it_be(:project) { build(:project, :repository) } + let_it_be(:max_chars) { 100 } + let_it_be(:user) do + user = create(:user, username: 'gfm') + project.add_maintainer(user) + user + end + + # Since we're truncating nodes of an html document, actually use the + # full pipeline to generate full documents. + def convert_markdown(text, context = {}) + Banzai::Pipeline::FullPipeline.to_html(text, { project: project }.merge(context)) + end + + shared_examples_for 'truncates text' do + specify do + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + expect(doc.to_html).to match(expected) + end + end + + describe 'displays inline code' do + let(:markdown) { 'Text with `inline code`' } + let(:expected) { 'Text with <code>inline code</code>' } + + it_behaves_like 'truncates text' + end + + describe 'truncates the text with multiple paragraphs' do + let(:markdown) { "Paragraph 1\n\nParagraph 2" } + let(:expected) { 'Paragraph 1...' } + + it_behaves_like 'truncates text' + end + + describe 'truncates the first line of a code block' do + let(:markdown) { "```\nCode block\nwith two lines\n```" } + let(:expected) { "Code block...</span>\n</code>" } + + it_behaves_like 'truncates text' + end + + describe 'preserves code color scheme' do + let(:max_chars) { 150 } + let(:markdown) { "```ruby\ndef test\n 'hello world'\nend\n```" } + let(:expected) do + '<code><span id="LC1" class="line" lang="ruby">' \ + '<span class="k">def</span> <span class="nf">test</span>...</span>' + end + + it_behaves_like 'truncates text' + end + + describe 'truncates a single long line of text' do + let(:max_chars) { 150 } + let(:text) { 'The quick brown fox jumped over the lazy dog twice' } # 50 chars + let(:markdown) { text * 4 } + let(:expected) { (text * 2).sub(/.{3}/, '...') } + + it_behaves_like 'truncates text' + end + + it 'preserves a link href when link text is truncated' do + max_chars = 150 + text = 'The quick brown fox jumped over the lazy dog' # 44 chars + link_url = 'http://example.com/foo/bar/baz' # 30 chars + markdown = "#{text}#{text}#{text} #{link_url}" # 163 chars + expected_link_text = 'http://example...</a>' + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + expect(doc.to_html).to match(link_url) + expect(doc.to_html).to match(expected_link_text) + end + + it 'truncates HTML properly' do + markdown = "@#{user.username}, can you look at this?\nHello world\n" + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + # Make sure we didn't create invalid markup + expect(doc.errors).to be_empty + + # Leading user link + expect(doc.css('a').length).to eq(1) + expect(doc.css('a')[0].attr('href')).to eq urls.user_path(user) + expect(doc.css('a')[0].text).to eq "@#{user.username}" + expect(doc.content).to eq "@#{user.username}, can you look at this?..." + end + + it 'truncates HTML with emoji properly' do + markdown = "foo :wink:\nbar :grinning:" + # actual = first_line_in_markdown(object, attribute, 100, project: project) + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + # Make sure we didn't create invalid markup + # But also account for the 2 errors caused by the unknown `gl-emoji` elements + expect(doc.errors.length).to eq(2) + + expect(doc.css('gl-emoji').length).to eq(2) + expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' + expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + + expect(doc.content).to eq "foo 😉\nbar 😀" + end + + it 'does not truncate if truncate_visible_max_chars not specified' do + markdown = "@#{user.username}, can you look at this?\nHello world" + + html = convert_markdown(markdown) + doc = filter(html) + + expect(doc.content).to eq markdown + end +end diff --git a/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb b/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb new file mode 100644 index 00000000000..422d0655e36 --- /dev/null +++ b/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AdjustTaskNoteRenameBackgroundMigrationValues, :migration do + let(:finished_status) { 3 } + let(:failed_status) { described_class::MIGRATION_FAILED_STATUS } + let(:active_status) { described_class::MIGRATION_ACTIVE_STATUS } + + shared_examples 'task note migration with failing batches' do + it 'updates batch sizes and resets failed batches' do + migration = create_background_migration(status: initial_status) + batches = [] + + batches << create_failed_batched_job(migration) + batches << create_failed_batched_job(migration) + + migrate! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::NEW_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::NEW_SUB_BATCH_SIZE + ) + expect(migration.reload.status).to eq(active_status) + + updated_batches = batches.map { |b| b.reload.attributes.slice('attempts', 'sub_batch_size') } + expect(updated_batches).to all(eq("attempts" => 0, "sub_batch_size" => 10)) + end + end + + describe '#up' do + context 'when migration was already finished' do + it 'does not update batch sizes' do + create_background_migration(status: finished_status) + + migrate! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::OLD_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + end + + context 'when the migration had failing batches' do + context 'when migration had a failed status' do + it_behaves_like 'task note migration with failing batches' do + let(:initial_status) { failed_status } + end + + it 'updates started_at timestamp' do + migration = create_background_migration(status: failed_status) + now = Time.zone.now + + travel_to now do + migrate! + migration.reload + end + + expect(migration.started_at).to be_like_time(now) + end + end + + context 'when migration had an active status' do + it_behaves_like 'task note migration with failing batches' do + let(:initial_status) { active_status } + end + + it 'does not update started_at timestamp' do + migration = create_background_migration(status: active_status) + original_time = migration.started_at + + migrate! + migration.reload + + expect(migration.started_at).to be_like_time(original_time) + end + end + end + end + + describe '#down' do + it 'reverts to old batch sizes' do + create_background_migration(status: finished_status) + + migrate! + schema_migrate_down! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::OLD_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + end + + def create_failed_batched_job(migration) + table(:batched_background_migration_jobs).create!( + batched_background_migration_id: migration.id, + status: described_class::JOB_FAILED_STATUS, + min_value: 1, + max_value: 10, + attempts: 3, + batch_size: described_class::OLD_BATCH_SIZE, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + + def create_background_migration(status:) + migrations_table = table(:batched_background_migrations) + # make sure we only have on migration with that job class name in the specs + migrations_table.where(job_class_name: described_class::JOB_CLASS_NAME).delete_all + + migrations_table.create!( + job_class_name: described_class::JOB_CLASS_NAME, + status: status, + max_value: 10, + max_batch_size: 20_000, + batch_size: described_class::OLD_BATCH_SIZE, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE, + interval: 2.minutes, + table_name: :system_note_metadata, + column_name: :id, + total_tuple_count: 100_000, + pause_ms: 100, + gitlab_schema: :gitlab_main, + job_arguments: [], + started_at: 2.days.ago + ) + end +end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 414a201aa34..04df8ecc882 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -178,8 +178,8 @@ RSpec.describe Member do end context 'member role is associated' do - let_it_be(:member_role) do - create(:member_role, members: [member]) + let!(:member_role) do + create(:member_role, members: [member], base_access_level: Gitlab::Access::DEVELOPER) end context 'member role matches access level' do @@ -201,7 +201,9 @@ RSpec.describe Member do member.access_level = Gitlab::Access::MAINTAINER expect(member).not_to be_valid - expect(member.errors.full_messages).to include( "Access level cannot be changed since member is associated with a custom role") + expect(member.errors.full_messages).to include( + "Access level cannot be changed since member is associated with a custom role" + ) end end end diff --git a/spec/models/members/member_role_spec.rb b/spec/models/members/member_role_spec.rb index e8993491918..e2691e2e78c 100644 --- a/spec/models/members/member_role_spec.rb +++ b/spec/models/members/member_role_spec.rb @@ -11,7 +11,39 @@ RSpec.describe MemberRole do describe 'validation' do subject { described_class.new } - it { is_expected.to validate_presence_of(:namespace_id) } + it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:base_access_level) } + + context 'for namespace' do + subject { build(:member_role) } + + let_it_be(:root_group) { create(:group) } + + context 'when namespace is a subgroup' do + it 'is invalid' do + subgroup = create(:group, parent: root_group) + subject.namespace = subgroup + + expect(subject).to be_invalid + end + end + + context 'when namespace is a root group' do + it 'is valid' do + subject.namespace = root_group + + expect(subject).to be_valid + end + end + + context 'when namespace is not present' do + it 'is invalid with a different error message' do + subject.namespace = nil + + expect(subject).to be_invalid + expect(subject.errors.full_messages).to eq(["Namespace can't be blank"]) + end + end + end end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 847379af91a..49709d47645 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -2777,6 +2777,50 @@ RSpec.describe ProjectPolicy do end end + describe 'role_enables_download_code' do + using RSpec::Parameterized::TableSyntax + + context 'default roles' do + let(:current_user) { public_send(role) } + + context 'public project' do + let(:project) { public_project } + + where(:role, :allowed) do + :owner | true + :maintainer | true + :developer | true + :reporter | true + :guest | true + + with_them do + it do + expect(subject.can?(:download_code)).to be(allowed) + end + end + end + end + + context 'private project' do + let(:project) { private_project } + + where(:role, :allowed) do + :owner | true + :maintainer | true + :developer | true + :reporter | true + :guest | false + end + + with_them do + it do + expect(subject.can?(:download_code)).to be(allowed) + end + end + end + end + end + private def project_subject(project_type) diff --git a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb index c19defa37e8..2dc7b9764fe 100644 --- a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb +++ b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb @@ -48,6 +48,8 @@ RSpec.describe 'Getting Ci Cd Setting' do expect(settings_data['mergeTrainsEnabled']).to eql project.ci_cd_settings.merge_trains_enabled? expect(settings_data['keepLatestArtifact']).to eql project.keep_latest_artifacts_available? expect(settings_data['jobTokenScopeEnabled']).to eql project.ci_cd_settings.job_token_scope_enabled? + expect(settings_data['inboundJobTokenScopeEnabled']).to eql( + project.ci_cd_settings.inbound_job_token_scope_enabled?) end end end diff --git a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb index 394d9ff53d1..6cca618726b 100644 --- a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb @@ -6,15 +6,19 @@ RSpec.describe 'ProjectCiCdSettingsUpdate' do include GraphqlHelpers let_it_be(:project) do - create(:project, keep_latest_artifact: true, ci_job_token_scope_enabled: true) - .tap(&:save!) + create(:project, + keep_latest_artifact: true, + ci_job_token_scope_enabled: true, + ci_inbound_job_token_scope_enabled: true + ).tap(&:save!) end let(:variables) do { full_path: project.full_path, keep_latest_artifact: false, - job_token_scope_enabled: false + job_token_scope_enabled: false, + inbound_job_token_scope_enabled: false } end @@ -76,6 +80,43 @@ RSpec.describe 'ProjectCiCdSettingsUpdate' do expect(project.ci_job_token_scope_enabled).to eq(true) end + describe 'inbound_job_token_scope_enabled' do + it 'updates inbound_job_token_scope_enabled' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(false) + end + + it 'does not update inbound_job_token_scope_enabled if not specified' do + variables.except!(:inbound_job_token_scope_enabled) + + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(true) + end + + context 'when ci_inbound_job_token_scope disabled' do + before do + stub_feature_flags(ci_inbound_job_token_scope: false) + end + + it 'does not update inbound_job_token_scope_enabled' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(true) + end + end + end + context 'when bad arguments are provided' do let(:variables) { { full_path: '', keep_latest_artifact: false } } |