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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-10-13 21:10:20 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-10-13 21:10:20 +0300
commitb1928c08f1642be0f66f6fa2587177b95a1cedc1 (patch)
tree35cc089bc6692db0135437fe7834928fc64052fe
parentbd25f1d9c685039381df23e49bc52cdcf4ec1b4a (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab-ci.yml8
-rw-r--r--.gitlab/ci/_skip.yml2
-rw-r--r--.gitlab/ci/docs.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/frontend.gitlab-ci.yml6
-rw-r--r--.gitlab/ci/global.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/notify.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/releases.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/review-apps/main.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/setup.gitlab-ci.yml6
-rw-r--r--.gitlab/ci/test-metadata.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/workhorse.gitlab-ci.yml2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js10
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue252
-rw-r--r--app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql9
-rw-r--r--app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql11
-rw-r--r--app/assets/javascripts/pipeline_new/graphql/resolvers.js29
-rw-r--r--app/assets/javascripts/pipeline_new/index.js14
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue85
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue2
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue5
-rw-r--r--app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue2
-rw-r--r--app/graphql/mutations/ci/project_ci_cd_settings_update.rb11
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb11
-rw-r--r--app/helpers/markup_helper.rb55
-rw-r--r--app/models/members/member_role.rb11
-rw-r--r--app/views/projects/pipelines/new.html.haml1
-rw-r--r--config/feature_flags/development/ci_inbound_job_token_scope.yml8
-rw-r--r--db/post_migrate/20221006172302_adjust_task_note_rename_background_migration_values.rb72
-rw-r--r--db/schema_migrations/202210061723021
-rw-r--r--doc/api/graphql/reference/index.md9
-rw-r--r--doc/ci/pipelines/cicd_minutes.md2
-rw-r--r--doc/development/pipelines.md24
-rw-r--r--doc/user/clusters/agent/work_with_agent.md4
-rw-r--r--lib/banzai/filter/truncate_visible_filter.rb69
-rw-r--r--lib/banzai/pipeline/post_process_pipeline.rb1
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/frontend/pipeline_new/components/pipeline_new_form_spec.js242
-rw-r--r--spec/frontend/pipeline_new/mock_data.js59
-rw-r--r--spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js102
-rw-r--r--spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js15
-rw-r--r--spec/frontend/pipeline_schedules/mock_data.js11
-rw-r--r--spec/helpers/markup_helper_spec.rb60
-rw-r--r--spec/lib/banzai/filter/truncate_visible_filter_spec.rb128
-rw-r--r--spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb143
-rw-r--r--spec/models/member_spec.rb8
-rw-r--r--spec/models/members/member_role_spec.rb34
-rw-r--r--spec/policies/project_policy_spec.rb44
-rw-r--r--spec/requests/api/graphql/ci/ci_cd_setting_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb47
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 } }