summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-14 12:09:14 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-14 12:09:14 +0000
commit49089d4fb1f5c17328ac61c955d95a68c6d4d545 (patch)
tree309d97ce6cbc1b22935dd0e11cc72abd767ffcf3
parent846a84f2e9d6149b00c63ccae2850421f6766bac (diff)
downloadgitlab-ce-49089d4fb1f5c17328ac61c955d95a68c6d4d545.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js12
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js8
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue38
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue57
-rw-r--r--app/assets/javascripts/registry/shared/constants.js2
-rw-r--r--app/assets/javascripts/registry/shared/utils.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js11
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/projects/services_controller.rb2
-rw-r--r--app/helpers/icons_helper.rb10
-rw-r--r--app/helpers/projects_helper.rb22
-rw-r--r--app/models/ci/job_artifact.rb11
-rw-r--r--app/models/ci/pipeline.rb30
-rw-r--r--app/models/merge_request.rb10
-rw-r--r--app/models/project.rb12
-rw-r--r--app/models/project_services/confluence_service.rb97
-rw-r--r--app/models/service.rb2
-rw-r--r--app/services/branches/delete_service.rb7
-rw-r--r--app/services/ci/create_job_artifacts_service.rb12
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb2
-rw-r--r--app/services/ci/unlock_artifacts_service.rb33
-rw-r--r--app/services/git/branch_push_service.rb7
-rw-r--r--app/services/git/tag_push_service.rb18
-rw-r--r--app/services/tags/destroy_service.rb8
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml14
-rw-r--r--app/workers/all_queues.yml16
-rw-r--r--app/workers/ci/pipeline_success_unlock_artifacts_worker.rb20
-rw-r--r--app/workers/ci/ref_delete_unlock_artifacts_worker.rb22
-rw-r--r--changelogs/unreleased/209912-memoize-sprites-icon-path.yml5
-rw-r--r--changelogs/unreleased/220413-quickly-resolve-issues-with-your-cleanup-policy-with-improved-vali.yml5
-rw-r--r--changelogs/unreleased/223928-page-title-editorconfig.yml5
-rw-r--r--changelogs/unreleased/228674-auto-devops-deploy-failure-when-code-quality-enabled.yml5
-rw-r--r--changelogs/unreleased/36361-custom-renderer-font-awesome.yml5
-rw-r--r--changelogs/unreleased/fix-uninitialized-constant.yml5
-rw-r--r--changelogs/unreleased/pages-1-21-0.yml5
-rw-r--r--config/application.rb2
-rw-r--r--db/migrate/20200527211605_add_locked_to_ci_pipelines.rb19
-rw-r--r--db/migrate/20200625174052_add_partial_index_to_locked_pipelines.rb17
-rw-r--r--db/post_migrate/20200701070435_add_default_value_stream_to_groups_with_group_stages.rb55
-rw-r--r--db/post_migrate/20200701091253_validate_foreign_key_on_cycle_analytics_group_stages.rb19
-rw-r--r--db/structure.sql13
-rw-r--r--doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md2
-rw-r--r--doc/administration/troubleshooting/sidekiq.md2
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql1
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json6
-rw-r--r--doc/api/services.md67
-rw-r--r--doc/ci/docker/using_kaniko.md2
-rw-r--r--doc/user/project/integrations/overview.md1
-rw-r--r--lib/api/helpers/services_helpers.rb9
-rw-r--r--lib/gitlab/ci/features.rb8
-rw-r--r--lib/gitlab/ci/pipeline/chain/build.rb6
-rw-r--r--lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml2
-rw-r--r--lib/gitlab/diff/file_collection/merge_request_diff_base.rb10
-rw-r--r--lib/gitlab/import_export/project/import_export.yml1
-rw-r--r--lib/gitlab/marginalia/comment.rb4
-rw-r--r--locale/gitlab.pot36
-rw-r--r--spec/factories/services.rb6
-rw-r--r--spec/frontend/filtered_search/stores/recent_searches_store_spec.js9
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js309
-rw-r--r--spec/frontend/monitoring/components/charts/anomaly_spec.js133
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js5
-rw-r--r--spec/frontend/monitoring/graph_data.js38
-rw-r--r--spec/frontend/monitoring/mock_data.js130
-rw-r--r--spec/frontend/monitoring/utils_spec.js8
-rw-r--r--spec/frontend/registry/settings/components/settings_form_spec.js69
-rw-r--r--spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap2
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js69
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js23
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js34
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js33
-rw-r--r--spec/helpers/icons_helper_spec.rb9
-rw-r--r--spec/helpers/projects_helper_spec.rb16
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb44
-rw-r--r--spec/models/ci/job_artifact_spec.rb12
-rw-r--r--spec/models/ci/pipeline_spec.rb119
-rw-r--r--spec/models/merge_request_spec.rb46
-rw-r--r--spec/models/project_services/confluence_service_spec.rb90
-rw-r--r--spec/models/project_spec.rb41
-rw-r--r--spec/requests/api/jobs_spec.rb6
-rw-r--r--spec/services/branches/delete_service_spec.rb10
-rw-r--r--spec/services/ci/create_job_artifacts_service_spec.rb20
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb4
-rw-r--r--spec/services/ci/unlock_artifacts_service_spec.rb97
-rw-r--r--spec/services/git/branch_push_service_spec.rb31
-rw-r--r--spec/services/git/tag_push_service_spec.rb37
-rw-r--r--spec/services/tags/destroy_service_spec.rb10
-rw-r--r--spec/support/helpers/notification_helpers.rb12
-rw-r--r--spec/support/shared_contexts/services_shared_context.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb101
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb34
-rw-r--r--spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb67
-rw-r--r--spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb78
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb2
103 files changed, 1970 insertions, 767 deletions
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 39893559155..3500250a4b0 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.20.0
+1.21.0
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 7e7a2588951..0b9fe969da1 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -9,3 +9,5 @@ export const FILTER_TYPE = {
none: 'none',
any: 'any',
};
+
+export const MAX_HISTORY_SIZE = 5;
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index cdbc9ec84bd..423f123f71c 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -1,4 +1,6 @@
-import { uniq } from 'lodash';
+import { uniqWith, isEqual } from 'lodash';
+
+import { MAX_HISTORY_SIZE } from '../constants';
class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) {
@@ -17,8 +19,12 @@ class RecentSearchesStore {
}
setRecentSearches(searches = []) {
- const trimmedSearches = searches.map(search => search.trim());
- this.state.recentSearches = uniq(trimmedSearches).slice(0, 5);
+ const trimmedSearches = searches.map(search =>
+ typeof search === 'string' ? search.trim() : search,
+ );
+
+ // Do object equality check to remove duplicates.
+ this.state.recentSearches = uniqWith(trimmedSearches, isEqual).slice(0, MAX_HISTORY_SIZE);
return this.state.recentSearches;
}
}
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 1ebf14d6eb7..c0cb924e749 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -79,11 +79,13 @@ export const getFileData = (
return service
.getFileData(url)
.then(({ data }) => {
- setPageTitleForFile(state, file);
-
if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
- if (makeFileActive) dispatch('setFileActive', path);
+
+ if (makeFileActive) {
+ setPageTitleForFile(state, file);
+ dispatch('setFileActive', path);
+ }
})
.catch(() => {
dispatch('setErrorMessage', {
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index 63dc4430d18..6a41e162af9 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,4 +1,5 @@
<script>
+import { get } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlCard, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
@@ -31,7 +32,8 @@ export default {
tracking: {
label: 'docker_container_retention_and_expiration_policies',
},
- formIsValid: true,
+ fieldsAreValid: true,
+ apiErrors: null,
};
},
computed: {
@@ -39,7 +41,7 @@ export default {
...mapGetters({ isEdited: 'getIsEdited' }),
...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
isSubmitButtonDisabled() {
- return !this.formIsValid || this.isLoading;
+ return !this.fieldsAreValid || this.isLoading;
},
isCancelButtonDisabled() {
return !this.isEdited || this.isLoading;
@@ -49,13 +51,35 @@ export default {
...mapActions(['resetSettings', 'saveSettings']),
reset() {
this.track('reset_form');
+ this.apiErrors = null;
this.resetSettings();
},
+ setApiErrors(response) {
+ const messages = get(response, 'data.message', []);
+
+ this.apiErrors = Object.keys(messages).reduce((acc, curr) => {
+ if (curr.startsWith('container_expiration_policy.')) {
+ const key = curr.replace('container_expiration_policy.', '');
+ acc[key] = get(messages, [curr, 0], '');
+ }
+ return acc;
+ }, {});
+ },
submit() {
this.track('submit_form');
+ this.apiErrors = null;
this.saveSettings()
.then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
- .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }));
+ .catch(({ response }) => {
+ this.setApiErrors(response);
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ });
+ },
+ onModelChange(changePayload) {
+ this.settings = changePayload.newValue;
+ if (this.apiErrors) {
+ this.apiErrors[changePayload.modified] = undefined;
+ }
},
},
};
@@ -69,11 +93,13 @@ export default {
</template>
<template #default>
<expiration-policy-fields
- v-model="settings"
+ :value="settings"
:form-options="formOptions"
:is-loading="isLoading"
- @validated="formIsValid = true"
- @invalidated="formIsValid = false"
+ :api-errors="apiErrors"
+ @validated="fieldsAreValid = true"
+ @invalidated="fieldsAreValid = false"
+ @input="onModelChange"
/>
</template>
<template #footer>
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 4cce006c4a3..54d7d195734 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -34,6 +34,11 @@ export default {
required: false,
default: () => ({}),
},
+ apiErrors: {
+ type: Object,
+ required: false,
+ default: null,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -56,9 +61,8 @@ export default {
},
},
i18n: {
- textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK,
- enableToggleLabel: ENABLE_TOGGLE_LABEL,
- enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION,
+ ENABLE_TOGGLE_LABEL,
+ ENABLE_TOGGLE_DESCRIPTION,
},
selectList: [
{
@@ -86,7 +90,6 @@ export default {
label: NAME_REGEX_LABEL,
model: 'name_regex',
placeholder: NAME_REGEX_PLACEHOLDER,
- stateVariable: 'nameRegexState',
description: NAME_REGEX_DESCRIPTION,
},
{
@@ -94,7 +97,6 @@ export default {
label: NAME_REGEX_KEEP_LABEL,
model: 'name_regex_keep',
placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
- stateVariable: 'nameKeepRegexState',
description: NAME_REGEX_KEEP_DESCRIPTION,
},
],
@@ -111,16 +113,34 @@ export default {
policyEnabledText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
- textAreaState() {
+ textAreaValidation() {
+ const nameRegexErrors =
+ this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex);
+ const nameKeepRegexErrors =
+ this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep);
+
return {
- nameRegexState: this.validateNameRegex(this.name_regex),
- nameKeepRegexState: this.validateNameRegex(this.name_regex_keep),
+ /*
+ * The state has this form:
+ * null: gray border, no message
+ * true: green border, no message ( because none is configured)
+ * false: red border, error message
+ * So in this function we keep null if the are no message otherwise we 'invert' the error message
+ */
+ name_regex: {
+ state: nameRegexErrors === null ? null : !nameRegexErrors,
+ message: nameRegexErrors,
+ },
+ name_regex_keep: {
+ state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
+ message: nameKeepRegexErrors,
+ },
};
},
fieldsValidity() {
return (
- this.textAreaState.nameRegexState !== false &&
- this.textAreaState.nameKeepRegexState !== false
+ this.textAreaValidation.name_regex.state !== false &&
+ this.textAreaValidation.name_regex_keep.state !== false
);
},
isFormElementDisabled() {
@@ -140,8 +160,11 @@ export default {
},
},
methods: {
- validateNameRegex(value) {
- return value ? value.length <= NAME_REGEX_LENGTH : null;
+ validateRegexLength(value) {
+ if (!value) {
+ return null;
+ }
+ return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK;
},
idGenerator(id) {
return `${id}_${this.uniqueId}`;
@@ -160,7 +183,7 @@ export default {
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator('expiration-policy-toggle')"
- :label="$options.i18n.enableToggleLabel"
+ :label="$options.i18n.ENABLE_TOGGLE_LABEL"
>
<div class="d-flex align-items-start">
<gl-toggle
@@ -169,7 +192,7 @@ export default {
:disabled="isLoading"
/>
<span class="mb-2 ml-2 lh-2">
- <gl-sprintf :message="$options.i18n.enableToggleDescription">
+ <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION">
<template #toggleStatus>
<strong>{{ policyEnabledText }}</strong>
</template>
@@ -210,8 +233,8 @@ export default {
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator(textarea.name)"
- :state="textAreaState[textarea.stateVariable]"
- :invalid-feedback="$options.i18n.textAreaInvalidFeedback"
+ :state="textAreaValidation[textarea.model].state"
+ :invalid-feedback="textAreaValidation[textarea.model].message"
>
<template #label>
<gl-sprintf :message="textarea.label">
@@ -224,7 +247,7 @@ export default {
:id="idGenerator(textarea.name)"
:value="value[textarea.model]"
:placeholder="textarea.placeholder"
- :state="textAreaState[textarea.stateVariable]"
+ :state="textAreaValidation[textarea.model].state"
:disabled="isFormElementDisabled"
trim
@input="updateModel($event, textarea.model)"
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 380c417f00e..36d55c7610e 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -23,7 +23,7 @@ export const ENABLE_TOGGLE_DESCRIPTION = s__(
);
export const TEXT_AREA_INVALID_FEEDBACK = s__(
- 'ContainerRegistry|The value of this input should be less than 255 characters',
+ 'ContainerRegistry|The value of this input should be less than 256 characters',
);
export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:');
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
index d85a3ad28c2..a7377773842 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -11,7 +11,7 @@ export const mapComputedToEvent = (list, root) => {
return this[root][e];
},
set(value) {
- this.$emit('input', { ...this[root], [e]: value });
+ this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e });
},
};
});
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 a858ffdbed5..34efc6afc6f 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
@@ -98,6 +98,15 @@ export default {
{},
);
},
+ tokenTitles() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.title,
+ }),
+ {},
+ );
+ },
sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest'
@@ -112,11 +121,10 @@ export default {
watch: {
/**
* GlFilteredSearch currently doesn't emit any event when
- * search field is cleared, but we still want our parent
- * component to know that filters were cleared and do
- * necessary data refetch, so this watcher is basically
- * a dirty hack/workaround to identify if filter input
- * was cleared. :(
+ * tokens are manually removed from search field so we'd
+ * never know when user actually clears all the tokens.
+ * This watcher listens for updates to `filterValue` on
+ * such instances. :(
*/
filterValue(value) {
const [firstVal] = value;
@@ -188,25 +196,16 @@ export default {
: SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
+ handleClearHistory() {
+ const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
+ this.recentSearchesService.save(resultantSearches);
+ },
handleFilterSubmit(filters) {
if (this.recentSearchesStorageKey) {
this.recentSearchesPromise
.then(() => {
if (filters.length) {
- const searchTokens = filters.map(filter => {
- // check filter was plain text search
- if (typeof filter === 'string') {
- return filter;
- }
- // filter was a token.
- return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
- filter.value.data
- }`;
- });
-
- const resultantSearches = this.recentSearchesStore.addRecentSearch(
- searchTokens.join(' '),
- );
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(filters);
this.recentSearchesService.save(resultantSearches);
}
})
@@ -228,8 +227,23 @@ export default {
:available-tokens="tokens"
:history-items="getRecentSearches()"
class="flex-grow-1"
+ @history-item-selected="$emit('onFilter', filters)"
+ @clear-history="handleClearHistory"
@submit="handleFilterSubmit"
- />
+ @clear="$emit('onFilter', [])"
+ >
+ <template #history-item="{ historyItem }">
+ <template v-for="token in historyItem">
+ <span v-if="typeof token === 'string'" :key="token" class="gl-px-1">"{{ token }}"</span>
+ <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1">
+ <span v-if="tokenTitles[token.type]"
+ >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
+ >
+ <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong>
+ </span>
+ </template>
+ </template>
+ </gl-filtered-search>
<gl-button-group class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index 412bfa5aa7f..d50649d2581 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -46,6 +46,16 @@ export default {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
},
},
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.authors.length) {
+ this.fetchAuthorBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
methods: {
fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
@@ -89,9 +99,9 @@ export default {
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template>
<template #suggestions>
- <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
- __('Any')
- }}</gl-filtered-search-suggestion>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">
+ {{ __('Any') }}
+ </gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
index f0f807c403f..7bb6c275832 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
@@ -3,7 +3,9 @@ import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
+import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
+const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
const htmlRenderers = [renderHtml];
const listRenderers = [renderKramdownList];
const paragraphRenderers = [renderIdentifierParagraph];
@@ -26,7 +28,7 @@ const buildCustomRendererFunctions = (customRenderers, defaults) => {
};
const buildCustomHTMLRenderer = (
- customRenderers = { htmlBlock: [], list: [], paragraph: [], text: [] },
+ customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] },
) => {
const defaults = {
htmlBlock(node, context) {
@@ -34,6 +36,11 @@ const buildCustomHTMLRenderer = (
return executeRenderer(allHtmlRenderers, node, context);
},
+ htmlInline(node, context) {
+ const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers];
+
+ return executeRenderer(allHtmlInlineRenderers, node, context);
+ },
list(node, context) {
const allListRenderers = [...customRenderers.list, ...listRenderers];
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
index 0261c18dfcd..c81478a8405 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -2,9 +2,14 @@ const buildToken = (type, tagName, props) => {
return { type, tagName, ...props };
};
-export const buildUneditableOpenTokens = token => {
+const TAG_TYPES = {
+ block: 'div',
+ inline: 'span',
+};
+
+export const buildUneditableOpenTokens = (token, type = TAG_TYPES.block) => {
return [
- buildToken('openTag', 'div', {
+ buildToken('openTag', type, {
attributes: { contenteditable: false },
classNames: [
'gl-px-4 gl-py-2 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
@@ -14,10 +19,17 @@ export const buildUneditableOpenTokens = token => {
];
};
-export const buildUneditableCloseToken = () => buildToken('closeTag', 'div');
+export const buildUneditableCloseToken = (type = TAG_TYPES.block) => buildToken('closeTag', type);
+
+export const buildUneditableCloseTokens = (token, type = TAG_TYPES.block) => {
+ return [token, buildUneditableCloseToken(type)];
+};
-export const buildUneditableCloseTokens = token => {
- return [token, buildToken('closeTag', 'div')];
+export const buildUneditableInlineTokens = token => {
+ return [
+ ...buildUneditableOpenTokens(token, TAG_TYPES.inline),
+ buildUneditableCloseToken(TAG_TYPES.inline),
+ ];
};
export const buildUneditableTokens = token => {
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
new file mode 100644
index 00000000000..572f6e3cf9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
@@ -0,0 +1,11 @@
+import { buildUneditableInlineTokens } from './build_uneditable_token';
+
+const fontAwesomeRegexOpen = /<i class="fa.+>/;
+
+const canRender = ({ literal }) => {
+ return fontAwesomeRegexOpen.test(literal);
+};
+
+const render = (_, { origin }) => buildUneditableInlineTokens(origin());
+
+export default { canRender, render };
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index a3e986572ef..cc341f13713 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -22,6 +22,7 @@ module ServiceParams
:comment_on_event_enabled,
:comment_detail,
:confidential_issues_events,
+ :confluence_url,
:default_irc_uri,
:device,
:disable_diffs,
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 85f02d699b1..440d20e1db2 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -61,7 +61,7 @@ class Projects::ServicesController < Projects::ApplicationController
return { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false }
end
- result = Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute
+ result = ::Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute
unless result[:success]
return { error: true, message: _('Test failed.'), service_response: result[:message].to_s, test_failed: true }
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index aeba88d4939..add15cc0d12 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -28,10 +28,12 @@ module IconsHelper
end
def sprite_icon_path
- # SVG Sprites currently don't work across domains, so in the case of a CDN
- # we have to set the current path deliberately to prevent addition of asset_host
- sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
- ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
+ @sprite_icon_path ||= begin
+ # SVG Sprites currently don't work across domains, so in the case of a CDN
+ # we have to set the current path deliberately to prevent addition of asset_host
+ sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
+ ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
+ end
end
def sprite_file_icons_path
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 49f15e03938..ed4285eb0fd 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -400,7 +400,7 @@ module ProjectsHelper
nav_tabs = [:home]
unless project.empty_repo?
- nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
+ nav_tabs += [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
nav_tabs << :releases if can?(current_user, :read_release, project)
end
@@ -421,30 +421,30 @@ module ProjectsHelper
nav_tabs << :operations
end
- if can?(current_user, :read_cycle_analytics, project)
- nav_tabs << :cycle_analytics
- end
-
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
end
end
- nav_tabs << external_nav_tabs(project)
+ apply_external_nav_tabs(nav_tabs, project)
- nav_tabs.flatten
+ nav_tabs
end
- def external_nav_tabs(project)
- [].tap do |tabs|
- tabs << :external_issue_tracker if project.external_issue_tracker
- tabs << :external_wiki if project.external_wiki
+ def apply_external_nav_tabs(nav_tabs, project)
+ nav_tabs << :external_issue_tracker if project.external_issue_tracker
+ nav_tabs << :external_wiki if project.external_wiki
+
+ if project.has_confluence?
+ nav_tabs.delete(:wiki)
+ nav_tabs << :confluence
end
end
def tab_ability_map
{
+ cycle_analytics: :read_cycle_analytics,
environments: :read_environment,
metrics_dashboards: :metrics_dashboard,
milestones: :read_milestone,
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index d20fdce10ef..1f207eaf370 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -7,10 +7,13 @@ module Ci
include UpdateProjectStatistics
include UsageStatistics
include Sortable
+ include IgnorableColumns
extend Gitlab::Ci::Model
NotSupportedAdapterError = Class.new(StandardError)
+ ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4'
+
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
@@ -108,10 +111,6 @@ module Ci
PLAN_LIMIT_PREFIX = 'ci_max_artifact_size_'
- # This is required since we cannot add a default to the database
- # https://gitlab.com/gitlab-org/gitlab/-/issues/215418
- attribute :locked, :boolean, default: false
-
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
@@ -130,7 +129,6 @@ module Ci
scope :with_files_stored_locally, -> { where(file_store: ::JobArtifactUploader::Store::LOCAL) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
- scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_file_types, -> (file_types) do
@@ -167,8 +165,7 @@ module Ci
scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) }
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
- scope :locked, -> { where(locked: true) }
- scope :unlocked, -> { where(locked: [false, nil]) }
+ scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked).order(expire_at: :desc) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 18e8cceb687..2f3b32e1cf6 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -113,6 +113,8 @@ module Ci
# extend this `Hash` with new values.
enum failure_reason: ::Ci::PipelineEnums.failure_reasons
+ enum locked: { unlocked: 0, artifacts_locked: 1 }
+
state_machine :status, initial: :created do
event :enqueue do
transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
@@ -247,6 +249,14 @@ module Ci
pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) }
end
+
+ after_transition any => [:success] do |pipeline|
+ next unless Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(pipeline.project)
+
+ pipeline.run_after_commit do
+ Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(pipeline.id)
+ end
+ end
end
scope :internal, -> { where(source: internal_sources) }
@@ -260,6 +270,12 @@ module Ci
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
+ scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
+ scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
+
+ scope :outside_pipeline_family, ->(pipeline) do
+ where.not(id: pipeline.same_family_pipeline_ids)
+ end
scope :with_reports, -> (reports_scope) do
where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1))
@@ -801,12 +817,16 @@ module Ci
end
# If pipeline is a child of another pipeline, include the parent
- # and the siblings, otherwise return only itself.
+ # and the siblings, otherwise return only itself and children.
def same_family_pipeline_ids
if (parent = parent_pipeline)
- [parent.id] + parent.child_pipelines.pluck(:id)
+ Ci::Pipeline.where(id: parent.id)
+ .or(Ci::Pipeline.where(id: parent.child_pipelines.select(:id)))
+ .select(:id)
else
- [self.id]
+ Ci::Pipeline.where(id: self.id)
+ .or(Ci::Pipeline.where(id: self.child_pipelines.select(:id)))
+ .select(:id)
end
end
@@ -897,6 +917,10 @@ module Ci
end
end
+ def has_archive_artifacts?
+ complete? && builds.latest.with_existing_job_artifacts(Ci::JobArtifact.archive.or(Ci::JobArtifact.metadata)).exists?
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 09f600926c9..fbc9ade34e1 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1117,14 +1117,8 @@ class MergeRequest < ApplicationRecord
end
def source_branch_exists?
- if Feature.enabled?(:memoize_source_branch_merge_request, project)
- strong_memoize(:source_branch_exists) do
- next false unless self.source_project
-
- self.source_project.repository.branch_exists?(self.source_branch)
- end
- else
- return false unless self.source_project
+ strong_memoize(:source_branch_exists) do
+ next false unless self.source_project
self.source_project.repository.branch_exists?(self.source_branch)
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 4f4226caa9c..5183c03de47 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -169,6 +169,7 @@ class Project < ApplicationRecord
has_one :custom_issue_tracker_service
has_one :bugzilla_service
has_one :gitlab_issue_tracker_service, inverse_of: :project
+ has_one :confluence_service
has_one :external_wiki_service
has_one :prometheus_service, inverse_of: :project
has_one :mock_ci_service
@@ -1286,6 +1287,11 @@ class Project < ApplicationRecord
update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write?
end
+ def has_confluence?
+ ConfluenceService.feature_enabled?(self) && # rubocop:disable CodeReuse/ServiceClass
+ project_setting.has_confluence?
+ end
+
def find_or_initialize_services
available_services_names = Service.available_services_names - disabled_services
@@ -1295,7 +1301,11 @@ class Project < ApplicationRecord
end
def disabled_services
- []
+ strong_memoize(:disabled_services) do
+ [].tap do |disabled_services|
+ disabled_services.push(ConfluenceService.to_param) unless ConfluenceService.feature_enabled?(self) # rubocop:disable CodeReuse/ServiceClass
+ end
+ end
end
def find_or_initialize_service(name)
diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb
new file mode 100644
index 00000000000..23ac5676b39
--- /dev/null
+++ b/app/models/project_services/confluence_service.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+class ConfluenceService < Service
+ include ActionView::Helpers::UrlHelper
+
+ VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze
+ VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
+ VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
+
+ FEATURE_FLAG = :confluence_integration
+
+ prop_accessor :confluence_url
+
+ validates :confluence_url, presence: true, if: :activated?
+ validate :validate_confluence_url_is_cloud, if: :activated?
+
+ after_commit :cache_project_has_confluence
+
+ def self.feature_enabled?(actor)
+ ::Feature.enabled?(FEATURE_FLAG, actor)
+ end
+
+ def self.to_param
+ 'confluence'
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def title
+ s_('ConfluenceService|Confluence Workspace')
+ end
+
+ def description
+ s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project')
+ end
+
+ def detailed_description
+ return unless project.wiki_enabled?
+
+ if activated?
+ wiki_url = project.wiki.web_url
+
+ s_(
+ 'ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration' %
+ { wiki_link: link_to(wiki_url, wiki_url) }
+ ).html_safe
+ else
+ s_('ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration').html_safe
+ end
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'confluence_url',
+ title: 'Confluence Cloud Workspace URL',
+ placeholder: s_('ConfluenceService|The URL of the Confluence Workspace'),
+ required: true
+ }
+ ]
+ end
+
+ def can_test?
+ false
+ end
+
+ private
+
+ def validate_confluence_url_is_cloud
+ unless confluence_uri_valid?
+ errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net')
+ end
+ end
+
+ def confluence_uri_valid?
+ return false unless confluence_url
+
+ uri = URI.parse(confluence_url)
+
+ (uri.scheme&.match(VALID_SCHEME_MATCH) &&
+ uri.host&.match(VALID_HOST_MATCH) &&
+ uri.path&.match(VALID_PATH_MATCH)).present?
+
+ rescue URI::InvalidURIError
+ false
+ end
+
+ def cache_project_has_confluence
+ return unless project && !project.destroyed?
+
+ project.project_setting.save! unless project.project_setting.persisted?
+ project.project_setting.update_column(:has_confluence, active?)
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index e310ff9abd2..5bbda1500cf 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -12,7 +12,7 @@ class Service < ApplicationRecord
ignore_columns %i[title description], remove_with: '13.4', remove_after: '2020-09-22'
SERVICE_NAMES = %w[
- alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord
+ alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb
index ca2b4556b58..9bd5b343448 100644
--- a/app/services/branches/delete_service.rb
+++ b/app/services/branches/delete_service.rb
@@ -19,6 +19,7 @@ module Branches
end
if repository.rm_branch(current_user, branch_name)
+ unlock_artifacts(branch_name)
ServiceResponse.success(message: 'Branch was deleted')
else
ServiceResponse.error(
@@ -28,5 +29,11 @@ module Branches
rescue Gitlab::Git::PreReceiveError => ex
ServiceResponse.error(message: ex.message, http_status: 400)
end
+
+ private
+
+ def unlock_artifacts(branch_name)
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{branch_name}")
+ end
end
end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
index a8b504e42bc..87717edbfc3 100644
--- a/app/services/ci/create_job_artifacts_service.rb
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -104,11 +104,6 @@ module Ci
expire_in: expire_in)
end
- if Feature.enabled?(:keep_latest_artifact_for_ref, project)
- artifact.locked = true
- artifact_metadata&.locked = true
- end
-
[artifact, artifact_metadata]
end
@@ -128,7 +123,6 @@ module Ci
Ci::JobArtifact.transaction do
artifact.save!
artifact_metadata&.save!
- unlock_previous_artifacts!
# NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
job.update_column(:artifacts_expire_at, artifact.expire_at)
@@ -146,12 +140,6 @@ module Ci
error(error.message, :bad_request)
end
- def unlock_previous_artifacts!
- return unless Feature.enabled?(:keep_latest_artifact_for_ref, project)
-
- Ci::JobArtifact.for_ref(job.ref, project.id).locked.update_all(locked: false)
- end
-
def sha256_matches_existing_artifact?(artifact_type, artifacts_file)
existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
return false unless existing_artifact
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 5deb84812ac..1fa8926faa1 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -28,7 +28,7 @@ module Ci
private
def destroy_batch
- artifact_batch = if Feature.enabled?(:keep_latest_artifact_for_ref)
+ artifact_batch = if Gitlab::Ci::Features.destroy_only_unlocked_expired_artifacts_enabled?
Ci::JobArtifact.expired(BATCH_SIZE).unlocked
else
Ci::JobArtifact.expired(BATCH_SIZE)
diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb
new file mode 100644
index 00000000000..07faf90dd6d
--- /dev/null
+++ b/app/services/ci/unlock_artifacts_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Ci
+ class UnlockArtifactsService < ::BaseService
+ BATCH_SIZE = 100
+
+ def execute(ci_ref, before_pipeline = nil)
+ query = <<~SQL.squish
+ UPDATE "ci_pipelines"
+ SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]}
+ WHERE "ci_pipelines"."id" in (
+ #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql}
+ LIMIT #{BATCH_SIZE}
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING "ci_pipelines"."id";
+ SQL
+
+ loop do
+ break if ActiveRecord::Base.connection.exec_query(query).empty?
+ end
+ end
+
+ private
+
+ def collect_pipelines(ci_ref, before_pipeline)
+ pipeline_scope = ci_ref.pipelines
+ pipeline_scope = pipeline_scope.before_pipeline(before_pipeline) if before_pipeline
+
+ pipeline_scope.artifacts_locked
+ end
+ end
+end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 5c1ee981d0c..2ec6ac99ece 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -29,6 +29,7 @@ module Git
perform_housekeeping
stop_environments
+ unlock_artifacts
true
end
@@ -60,6 +61,12 @@ module Git
Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
end
+ def unlock_artifacts
+ return unless removing_branch?
+
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref)
+ end
+
def execute_related_hooks
BranchHooksService.new(project, current_user, params).execute
end
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
index 9a266f7d74c..120c4cde94b 100644
--- a/app/services/git/tag_push_service.rb
+++ b/app/services/git/tag_push_service.rb
@@ -10,7 +10,25 @@ module Git
project.repository.before_push_tag
TagHooksService.new(project, current_user, params).execute
+ unlock_artifacts
+
true
end
+
+ private
+
+ def unlock_artifacts
+ return unless removing_tag?
+
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref)
+ end
+
+ def removing_tag?
+ Gitlab::Git.blank_ref?(newrev)
+ end
+
+ def tag_name
+ Gitlab::Git.ref_name(ref)
+ end
end
end
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index 3a01192487d..4d1f4043b01 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -18,6 +18,8 @@ module Tags
.new(project, current_user, tag: tag_name)
.execute
+ unlock_artifacts(tag_name)
+
success('Tag was removed')
else
error('Failed to remove tag')
@@ -33,5 +35,11 @@ module Tags
def success(message)
super().merge(message: message)
end
+
+ private
+
+ def unlock_artifacts(tag_name)
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::TAG_REF_PREFIX}#{tag_name}")
+ end
end
end
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index cefe4659430..5fc6b0a17b6 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -293,6 +293,20 @@
= render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
+ - if project_nav_tab?(:confluence)
+ - confluence_url = @project.confluence_service.confluence_url
+ = nav_link do
+ = link_to confluence_url, class: 'shortcuts-confluence' do
+ .nav-icon-container
+ = sprite_icon('external-link')
+ %span.nav-item-name
+ = _('Confluence')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: 'fly-out-top-item' } ) do
+ = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do
+ %strong.fly-out-top-item-name
+ = _('Confluence')
+
- if project_nav_tab? :wiki
- wiki_url = wiki_path(@project.wiki)
= nav_link(controller: :wikis) do
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 96928010773..62c0bcf0093 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -859,6 +859,22 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_pipeline_success_unlock_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: pipeline_background:ci_ref_delete_unlock_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_cache:expire_job_cache
:feature_category: :continuous_integration
:has_external_dependencies:
diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
new file mode 100644
index 00000000000..bc31876aa1d
--- /dev/null
+++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineSuccessUnlockArtifactsWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(pipeline_id)
+ ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ break unless pipeline.has_archive_artifacts?
+
+ ::Ci::UnlockArtifactsService
+ .new(pipeline.project, pipeline.user)
+ .execute(pipeline.ci_ref, pipeline)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb
new file mode 100644
index 00000000000..3b4a6fcf630
--- /dev/null
+++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+ class RefDeleteUnlockArtifactsWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(project_id, user_id, ref_path)
+ ::Project.find_by_id(project_id).try do |project|
+ ::User.find_by_id(user_id).try do |user|
+ ::Ci::Ref.find_by_ref_path(ref_path).try do |ci_ref|
+ ::Ci::UnlockArtifactsService
+ .new(project, user)
+ .execute(ci_ref)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/changelogs/unreleased/209912-memoize-sprites-icon-path.yml b/changelogs/unreleased/209912-memoize-sprites-icon-path.yml
new file mode 100644
index 00000000000..fa8dc87d11a
--- /dev/null
+++ b/changelogs/unreleased/209912-memoize-sprites-icon-path.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid N+1 calls for image_path when rendering commits
+merge_request: 36724
+author:
+type: performance
diff --git a/changelogs/unreleased/220413-quickly-resolve-issues-with-your-cleanup-policy-with-improved-vali.yml b/changelogs/unreleased/220413-quickly-resolve-issues-with-your-cleanup-policy-with-improved-vali.yml
new file mode 100644
index 00000000000..486698487c6
--- /dev/null
+++ b/changelogs/unreleased/220413-quickly-resolve-issues-with-your-cleanup-policy-with-improved-vali.yml
@@ -0,0 +1,5 @@
+---
+title: 'Cleanup policies: display API error messages under form field'
+merge_request: 36190
+author:
+type: changed
diff --git a/changelogs/unreleased/223928-page-title-editorconfig.yml b/changelogs/unreleased/223928-page-title-editorconfig.yml
new file mode 100644
index 00000000000..bbecbe498cf
--- /dev/null
+++ b/changelogs/unreleased/223928-page-title-editorconfig.yml
@@ -0,0 +1,5 @@
+---
+title: 'Web IDE: Page title should not be .editorconfig when the IDE is first loaded.'
+merge_request: 36783
+author:
+type: fixed
diff --git a/changelogs/unreleased/228674-auto-devops-deploy-failure-when-code-quality-enabled.yml b/changelogs/unreleased/228674-auto-devops-deploy-failure-when-code-quality-enabled.yml
new file mode 100644
index 00000000000..15145efa46c
--- /dev/null
+++ b/changelogs/unreleased/228674-auto-devops-deploy-failure-when-code-quality-enabled.yml
@@ -0,0 +1,5 @@
+---
+title: Do not depend on artifacts from previous stages in Auto DevOps deployments
+merge_request: 36741
+author:
+type: fixed
diff --git a/changelogs/unreleased/36361-custom-renderer-font-awesome.yml b/changelogs/unreleased/36361-custom-renderer-font-awesome.yml
new file mode 100644
index 00000000000..c3651f66350
--- /dev/null
+++ b/changelogs/unreleased/36361-custom-renderer-font-awesome.yml
@@ -0,0 +1,5 @@
+---
+title: Add a custom HTML renderer to the Static Site Editor for font awesome inline HTML syntax
+merge_request: 36361
+author:
+type: added
diff --git a/changelogs/unreleased/fix-uninitialized-constant.yml b/changelogs/unreleased/fix-uninitialized-constant.yml
new file mode 100644
index 00000000000..cc7a579e967
--- /dev/null
+++ b/changelogs/unreleased/fix-uninitialized-constant.yml
@@ -0,0 +1,5 @@
+---
+title: Fix error message when saving an integration and testing the settings.
+merge_request: 36700
+author:
+type: fixed
diff --git a/changelogs/unreleased/pages-1-21-0.yml b/changelogs/unreleased/pages-1-21-0.yml
new file mode 100644
index 00000000000..aee1f177a8e
--- /dev/null
+++ b/changelogs/unreleased/pages-1-21-0.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade GitLab Pages to 1.21.0
+merge_request: 36214
+author:
+type: added
diff --git a/config/application.rb b/config/application.rb
index 47f8ca48d91..772b3b042c1 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -158,6 +158,8 @@ module Gitlab
# Webpack dev server configuration is handled in initializers/static_files.rb
config.webpack.dev_server.enabled = false
+ config.action_mailer.delivery_job = "ActionMailer::MailDeliveryJob"
+
# Enable the asset pipeline
config.assets.enabled = true
diff --git a/db/migrate/20200527211605_add_locked_to_ci_pipelines.rb b/db/migrate/20200527211605_add_locked_to_ci_pipelines.rb
new file mode 100644
index 00000000000..3587e6c4a08
--- /dev/null
+++ b/db/migrate/20200527211605_add_locked_to_ci_pipelines.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddLockedToCiPipelines < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_column :ci_pipelines, :locked, :integer, limit: 2, null: false, default: 0
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :ci_pipelines, :locked
+ end
+ end
+end
diff --git a/db/migrate/20200625174052_add_partial_index_to_locked_pipelines.rb b/db/migrate/20200625174052_add_partial_index_to_locked_pipelines.rb
new file mode 100644
index 00000000000..85f706f5d31
--- /dev/null
+++ b/db/migrate/20200625174052_add_partial_index_to_locked_pipelines.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddPartialIndexToLockedPipelines < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :ci_pipelines, [:ci_ref_id, :id], name: 'idx_ci_pipelines_artifacts_locked', where: 'locked = 1'
+ end
+
+ def down
+ remove_concurrent_index :ci_pipelines, 'idx_ci_pipelines_artifacts_locked'
+ end
+end
diff --git a/db/post_migrate/20200701070435_add_default_value_stream_to_groups_with_group_stages.rb b/db/post_migrate/20200701070435_add_default_value_stream_to_groups_with_group_stages.rb
new file mode 100644
index 00000000000..971eb3c489f
--- /dev/null
+++ b/db/post_migrate/20200701070435_add_default_value_stream_to_groups_with_group_stages.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class AddDefaultValueStreamToGroupsWithGroupStages < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Group < ActiveRecord::Base
+ def self.find_sti_class(typename)
+ if typename == 'Group'
+ Group
+ else
+ super
+ end
+ end
+ self.table_name = 'namespaces'
+ has_many :group_value_streams
+ has_many :group_stages
+ end
+
+ class GroupValueStream < ActiveRecord::Base
+ self.table_name = 'analytics_cycle_analytics_group_value_streams'
+ has_many :group_stages
+ belongs_to :group
+ end
+
+ class GroupStage < ActiveRecord::Base
+ self.table_name = 'analytics_cycle_analytics_group_stages'
+ belongs_to :group_value_stream
+ end
+
+ def up
+ Group.where(type: 'Group').joins(:group_stages).distinct.find_each do |group|
+ Group.transaction do
+ group_value_stream = group.group_value_streams.first_or_create!(name: 'default')
+ group.group_stages.update_all(group_value_stream_id: group_value_stream.id)
+ end
+ end
+
+ change_column_null :analytics_cycle_analytics_group_stages, :group_value_stream_id, false
+ end
+
+ def down
+ change_column_null :analytics_cycle_analytics_group_stages, :group_value_stream_id, true
+
+ GroupValueStream.where(name: 'default').includes(:group_stages).find_each do |value_stream|
+ GroupValueStream.transaction do
+ value_stream.group_stages.update_all(group_value_stream_id: nil)
+ value_stream.destroy!
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20200701091253_validate_foreign_key_on_cycle_analytics_group_stages.rb b/db/post_migrate/20200701091253_validate_foreign_key_on_cycle_analytics_group_stages.rb
new file mode 100644
index 00000000000..0a8926ed6de
--- /dev/null
+++ b/db/post_migrate/20200701091253_validate_foreign_key_on_cycle_analytics_group_stages.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ValidateForeignKeyOnCycleAnalyticsGroupStages < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ # same as in db/migrate/20200701064756_add_not_valid_foreign_key_to_cycle_analytics_group_stages.rb
+ CONSTRAINT_NAME = 'fk_analytics_cycle_analytics_group_stages_group_value_stream_id'
+
+ def up
+ validate_foreign_key :analytics_cycle_analytics_group_stages, :group_value_stream_id, name: CONSTRAINT_NAME
+ end
+
+ def down
+ remove_foreign_key_if_exists :analytics_cycle_analytics_group_stages, column: :group_value_stream_id, name: CONSTRAINT_NAME
+ add_foreign_key :analytics_cycle_analytics_group_stages, :analytics_cycle_analytics_group_value_streams,
+ column: :group_value_stream_id, name: CONSTRAINT_NAME, on_delete: :cascade, validate: false
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 47fd702a68d..a23cb732c4e 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -8801,7 +8801,7 @@ CREATE TABLE public.analytics_cycle_analytics_group_stages (
hidden boolean DEFAULT false NOT NULL,
custom boolean DEFAULT true NOT NULL,
name character varying(255) NOT NULL,
- group_value_stream_id bigint
+ group_value_stream_id bigint NOT NULL
);
CREATE SEQUENCE public.analytics_cycle_analytics_group_stages_id_seq
@@ -10091,7 +10091,8 @@ CREATE TABLE public.ci_pipelines (
source_sha bytea,
target_sha bytea,
external_pull_request_id bigint,
- ci_ref_id bigint
+ ci_ref_id bigint,
+ locked smallint DEFAULT 0 NOT NULL
);
CREATE TABLE public.ci_pipelines_config (
@@ -18478,6 +18479,8 @@ CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_and_note_id_index ON public.ep
CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_index ON public.epic_user_mentions USING btree (epic_id) WHERE (note_id IS NULL);
+CREATE INDEX idx_ci_pipelines_artifacts_locked ON public.ci_pipelines USING btree (ci_ref_id, id) WHERE (locked = 1);
+
CREATE INDEX idx_deployment_clusters_on_cluster_id_and_kubernetes_namespace ON public.deployment_clusters USING btree (cluster_id, kubernetes_namespace);
CREATE UNIQUE INDEX idx_deployment_merge_requests_unique_index ON public.deployment_merge_requests USING btree (deployment_id, merge_request_id);
@@ -21260,7 +21263,7 @@ ALTER TABLE ONLY public.merge_request_metrics
ADD CONSTRAINT fk_ae440388cc FOREIGN KEY (latest_closed_by_id) REFERENCES public.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.analytics_cycle_analytics_group_stages
- ADD CONSTRAINT fk_analytics_cycle_analytics_group_stages_group_value_stream_id FOREIGN KEY (group_value_stream_id) REFERENCES public.analytics_cycle_analytics_group_value_streams(id) ON DELETE CASCADE NOT VALID;
+ ADD CONSTRAINT fk_analytics_cycle_analytics_group_stages_group_value_stream_id FOREIGN KEY (group_value_stream_id) REFERENCES public.analytics_cycle_analytics_group_value_streams(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.fork_network_members
ADD CONSTRAINT fk_b01280dae4 FOREIGN KEY (forked_from_project_id) REFERENCES public.projects(id) ON DELETE SET NULL;
@@ -23635,6 +23638,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200527152657
20200527170649
20200527211000
+20200527211605
20200528054112
20200528123703
20200528125905
@@ -23710,6 +23714,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200625045442
20200625082258
20200625113337
+20200625174052
20200625190458
20200626060151
20200626130220
@@ -23717,6 +23722,8 @@ COPY "schema_migrations" (version) FROM STDIN;
20200630091656
20200630110826
20200701064756
+20200701070435
+20200701091253
20200701093859
20200701205710
20200702123805
diff --git a/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md b/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md
index 69af7ea6801..4628c63b57f 100644
--- a/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md
+++ b/doc/administration/troubleshooting/navigating_gitlab_via_rails_console.md
@@ -186,7 +186,7 @@ user.save
Which would return:
```ruby
-Enqueued ActionMailer::DeliveryJob (Job ID: 05915c4e-c849-4e14-80bb-696d5ae22065) to Sidekiq(mailers) with arguments: "DeviseMailer", "password_change", "deliver_now", #<GlobalID:0x00007f42d8ccebe8 @uri=#<URI::GID gid://gitlab/User/1>>
+Enqueued ActionMailer::MailDeliveryJob (Job ID: 05915c4e-c849-4e14-80bb-696d5ae22065) to Sidekiq(mailers) with arguments: "DeviseMailer", "password_change", "deliver_now", #<GlobalID:0x00007f42d8ccebe8 @uri=#<URI::GID gid://gitlab/User/1>>
=> true
```
diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md
index 5109a3baff2..6ecb8a06f1d 100644
--- a/doc/administration/troubleshooting/sidekiq.md
+++ b/doc/administration/troubleshooting/sidekiq.md
@@ -38,7 +38,7 @@ Example log output:
```json
{"severity":"INFO","time":"2020-06-08T14:37:37.892Z","class":"AdminEmailsWorker","args":["[FILTERED]","[FILTERED]","[FILTERED]"],"retry":3,"queue":"admin_emails","backtrace":true,"jid":"9e35e2674ac7b12d123e13cc","created_at":"2020-06-08T14:37:37.373Z","meta.user":"root","meta.caller_id":"Admin::EmailsController#create","correlation_id":"37D3lArJmT1","uber-trace-id":"2d942cc98cc1b561:6dc94409cfdd4d77:9fbe19bdee865293:1","enqueued_at":"2020-06-08T14:37:37.410Z","pid":65011,"message":"AdminEmailsWorker JID-9e35e2674ac7b12d123e13cc: done: 0.48085 sec","job_status":"done","scheduling_latency_s":0.001012,"redis_calls":9,"redis_duration_s":0.004608,"redis_read_bytes":696,"redis_write_bytes":6141,"duration_s":0.48085,"cpu_s":0.308849,"completed_at":"2020-06-08T14:37:37.892Z","db_duration_s":0.010742}
-{"severity":"INFO","time":"2020-06-08T14:37:37.894Z","class":"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper","wrapped":"ActionMailer::DeliveryJob","queue":"mailers","args":["[FILTERED]"],"retry":3,"backtrace":true,"jid":"e47a4f6793d475378432e3c8","created_at":"2020-06-08T14:37:37.884Z","meta.user":"root","meta.caller_id":"AdminEmailsWorker","correlation_id":"37D3lArJmT1","uber-trace-id":"2d942cc98cc1b561:29344de0f966446d:5c3b0e0e1bef987b:1","enqueued_at":"2020-06-08T14:37:37.885Z","pid":65011,"message":"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper JID-e47a4f6793d475378432e3c8: start","job_status":"start","scheduling_latency_s":0.009473}
+{"severity":"INFO","time":"2020-06-08T14:37:37.894Z","class":"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper","wrapped":"ActionMailer::MailDeliveryJob","queue":"mailers","args":["[FILTERED]"],"retry":3,"backtrace":true,"jid":"e47a4f6793d475378432e3c8","created_at":"2020-06-08T14:37:37.884Z","meta.user":"root","meta.caller_id":"AdminEmailsWorker","correlation_id":"37D3lArJmT1","uber-trace-id":"2d942cc98cc1b561:29344de0f966446d:5c3b0e0e1bef987b:1","enqueued_at":"2020-06-08T14:37:37.885Z","pid":65011,"message":"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper JID-e47a4f6793d475378432e3c8: start","job_status":"start","scheduling_latency_s":0.009473}
{"severity":"INFO","time":"2020-06-08T14:39:50.648Z","class":"NewIssueWorker","args":["455","1"],"retry":3,"queue":"new_issue","backtrace":true,"jid":"a24af71f96fd129ec47f5d1e","created_at":"2020-06-08T14:39:50.643Z","meta.user":"root","meta.project":"h5bp/html5-boilerplate","meta.root_namespace":"h5bp","meta.caller_id":"Projects::IssuesController#create","correlation_id":"f9UCZHqhuP7","uber-trace-id":"28f65730f99f55a3:a5d2b62dec38dffc:48ddd092707fa1b7:1","enqueued_at":"2020-06-08T14:39:50.646Z","pid":65011,"message":"NewIssueWorker JID-a24af71f96fd129ec47f5d1e: start","job_status":"start","scheduling_latency_s":0.001144}
```
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index b7c5b631cee..470126d568d 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -12408,6 +12408,7 @@ enum ServiceType {
BUGZILLA_SERVICE
BUILDKITE_SERVICE
CAMPFIRE_SERVICE
+ CONFLUENCE_SERVICE
CUSTOM_ISSUE_TRACKER_SERVICE
DISCORD_SERVICE
DRONE_CI_SERVICE
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 131c387c767..d14ad46d205 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -36440,6 +36440,12 @@
"deprecationReason": null
},
{
+ "name": "CONFLUENCE_SERVICE",
+ "description": null,
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "CUSTOM_ISSUE_TRACKER_SERVICE",
"description": null,
"isDeprecated": false,
diff --git a/doc/api/services.md b/doc/api/services.md
index 145d83c62fa..c4bd5f86e43 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -493,6 +493,73 @@ Get Emails on push service settings for a project.
GET /projects/:id/services/emails-on-push
```
+## Confluence service
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/220934) in GitLab 13.2.
+> - It's deployed behind a feature flag, disabled by default.
+> - It's disabled on GitLab.com.
+> - It's able to be enabled or disabled per-project
+> - It's not recommended for production use.
+> - To use it in GitLab self-managed instances, ask a GitLab administrator to
+ [enable it](#enable-or-disable-the-confluence-service-core-only). **(CORE ONLY)**
+
+Replaces the link to the internal wiki with a link to a Confluence Cloud Workspace.
+
+### Create/Edit Confluence service
+
+Set Confluence service for a project.
+
+```plaintext
+PUT /projects/:id/services/confluence
+```
+
+Parameters:
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `confluence_url` | string | true | The URL of the Confluence Cloud Workspace hosted on atlassian.net. |
+
+### Delete Confluence service
+
+Delete Confluence service for a project.
+
+```plaintext
+DELETE /projects/:id/services/confluence
+```
+
+### Get Confluence service settings
+
+Get Confluence service settings for a project.
+
+```plaintext
+GET /projects/:id/services/confluence
+```
+
+### Enable or disable the Confluence service **(CORE ONLY)**
+
+The Confluence service is under development and not ready for production use. It is
+deployed behind a feature flag that is **disabled by default**.
+[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
+can enable it for your instance. The Confluence service can be enabled or disabled per-project
+
+To enable it:
+
+```ruby
+# Instance-wide
+Feature.enable(:confluence_integration)
+# or by project
+Feature.enable(:confluence_integration, Project.find(<project id>))
+```
+
+To disable it:
+
+```ruby
+# Instance-wide
+Feature.disable(:confluence_integration)
+# or by project
+Feature.disable(:confluence_integration, Project.find(<project id>))
+```
+
## External Wiki
Replaces the link to the internal wiki with a link to an external wiki.
diff --git a/doc/ci/docker/using_kaniko.md b/doc/ci/docker/using_kaniko.md
index d53430400ec..1580080ac6e 100644
--- a/doc/ci/docker/using_kaniko.md
+++ b/doc/ci/docker/using_kaniko.md
@@ -90,7 +90,7 @@ store:
- |
echo "-----BEGIN CERTIFICATE-----
...
- -----END CERTIFICATE-----" >> /kaniko/ssl/certs/ca-certificates.crt
+ -----END CERTIFICATE-----" >> /kaniko/ssl/certs/additional-ca-cert-bundle.crt
```
## Video walkthrough of a working example
diff --git a/doc/user/project/integrations/overview.md b/doc/user/project/integrations/overview.md
index 88668ab6c7d..98ee5f9f641 100644
--- a/doc/user/project/integrations/overview.md
+++ b/doc/user/project/integrations/overview.md
@@ -28,6 +28,7 @@ Click on the service links to see further configuration instructions and details
| Buildkite | Continuous integration and deployments | Yes |
| [Bugzilla](bugzilla.md) | Bugzilla issue tracker | No |
| Campfire | Simple web-based real-time group chat | No |
+| Confluence | Replaces the link to the internal wiki with a link to a Confluence Cloud Workspace. Service is behind a feature flag, disabled by default ([see details](../../../api/services.md#enable-or-disable-the-confluence-service-core-only)). | No |
| Custom Issue Tracker | Custom issue tracker | No |
| [Discord Notifications](discord_notifications.md) | Receive event notifications in Discord | No |
| Drone CI | Continuous Integration platform built on Docker, written in Go | Yes |
diff --git a/lib/api/helpers/services_helpers.rb b/lib/api/helpers/services_helpers.rb
index 3d6039cacaa..7f4846dc083 100644
--- a/lib/api/helpers/services_helpers.rb
+++ b/lib/api/helpers/services_helpers.rb
@@ -288,6 +288,14 @@ module API
desc: 'Campfire room'
}
],
+ 'confluence' => [
+ {
+ required: true,
+ name: :confluence_url,
+ type: String,
+ desc: 'The URL of the Confluence Cloud Workspace hosted on atlassian.net'
+ }
+ ],
'custom-issue-tracker' => [
{
required: true,
@@ -757,6 +765,7 @@ module API
::BambooService,
::BugzillaService,
::BuildkiteService,
+ ::ConfluenceService,
::CampfireService,
::CustomIssueTrackerService,
::DiscordService,
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index 1889b9e0731..554c30fadc8 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -53,6 +53,14 @@ module Gitlab
def self.raise_job_rules_without_workflow_rules_warning?
::Feature.enabled?(:ci_raise_job_rules_without_workflow_rules_warning)
end
+
+ def self.keep_latest_artifacts_for_ref_enabled?(project)
+ ::Feature.enabled?(:keep_latest_artifacts_for_ref, project, default_enabled: false)
+ end
+
+ def self.destroy_only_unlocked_expired_artifacts_enabled?
+ ::Feature.enabled?(:destroy_only_unlocked_expired_artifacts, default_enabled: false)
+ end
end
end
end
diff --git a/lib/gitlab/ci/pipeline/chain/build.rb b/lib/gitlab/ci/pipeline/chain/build.rb
index 9662209f88e..4190c40eb66 100644
--- a/lib/gitlab/ci/pipeline/chain/build.rb
+++ b/lib/gitlab/ci/pipeline/chain/build.rb
@@ -20,7 +20,11 @@ module Gitlab
pipeline_schedule: @command.schedule,
merge_request: @command.merge_request,
external_pull_request: @command.external_pull_request,
- variables_attributes: Array(@command.variables_attributes)
+ variables_attributes: Array(@command.variables_attributes),
+ # This should be removed and set on the database column default
+ # level when the keep_latest_artifacts_for_ref feature flag is
+ # removed.
+ locked: ::Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(@command.project) ? :artifacts_locked : :unlocked
)
end
diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
index 84ea6483cc1..cff4a15e70e 100644
--- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
@@ -1,5 +1,6 @@
.auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.17.0"
+ dependencies: []
include:
- template: Jobs/Deploy/ECS.gitlab-ci.yml
@@ -42,7 +43,6 @@ stop_review:
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
- dependencies: []
allow_failure: true
rules:
- if: '$CI_KUBERNETES_ACTIVE == null || $CI_KUBERNETES_ACTIVE == ""'
diff --git a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
index 077922dcb64..d54e1aad19a 100644
--- a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
+++ b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
@@ -29,13 +29,13 @@ module Gitlab
override :write_cache
def write_cache
highlight_cache.write_if_empty
- diff_stats_cache&.write_if_empty(diff_stats_collection)
+ diff_stats_cache.write_if_empty(diff_stats_collection)
end
override :clear_cache
def clear_cache
highlight_cache.clear
- diff_stats_cache&.clear
+ diff_stats_cache.clear
end
def real_size
@@ -52,9 +52,7 @@ module Gitlab
def diff_stats_cache
strong_memoize(:diff_stats_cache) do
- if Feature.enabled?(:cache_diff_stats_merge_request, project)
- Gitlab::Diff::StatsCache.new(cachable_key: @merge_request_diff.cache_key)
- end
+ Gitlab::Diff::StatsCache.new(cachable_key: @merge_request_diff.cache_key)
end
end
@@ -63,7 +61,7 @@ module Gitlab
strong_memoize(:diff_stats) do
next unless fetch_diff_stats?
- diff_stats_cache&.read || super
+ diff_stats_cache.read || super
end
end
end
diff --git a/lib/gitlab/import_export/project/import_export.yml b/lib/gitlab/import_export/project/import_export.yml
index 4bc7aeef21e..e3126719aea 100644
--- a/lib/gitlab/import_export/project/import_export.yml
+++ b/lib/gitlab/import_export/project/import_export.yml
@@ -309,6 +309,7 @@ excluded_attributes:
- :merge_request_id
- :external_pull_request_id
- :ci_ref_id
+ - :locked
stages:
- :pipeline_id
merge_access_levels:
diff --git a/lib/gitlab/marginalia/comment.rb b/lib/gitlab/marginalia/comment.rb
index a0eee823763..d5dae5ef4b3 100644
--- a/lib/gitlab/marginalia/comment.rb
+++ b/lib/gitlab/marginalia/comment.rb
@@ -26,9 +26,9 @@ module Gitlab
job = ::Marginalia::Comment.marginalia_job
# We are using 'Marginalia::SidekiqInstrumentation' which does not support 'ActiveJob::Base'.
- # Gitlab also uses 'ActionMailer::DeliveryJob' which inherits from ActiveJob::Base.
+ # Gitlab also uses 'ActionMailer::MailDeliveryJob' which inherits from ActiveJob::Base.
# So below condition is used to return metadata for such jobs.
- if job && job.is_a?(ActionMailer::DeliveryJob)
+ if job.is_a?(ActionMailer::MailDeliveryJob) || job.is_a?(ActionMailer::DeliveryJob)
{
"class" => job.arguments.first,
"jid" => job.job_id
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b479388b545..5b847b1e963 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6199,6 +6199,24 @@ msgstr ""
msgid "Confirmation required"
msgstr ""
+msgid "Confluence"
+msgstr ""
+
+msgid "ConfluenceService|Confluence Workspace"
+msgstr ""
+
+msgid "ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project"
+msgstr ""
+
+msgid "ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration"
+msgstr ""
+
+msgid "ConfluenceService|The URL of the Confluence Workspace"
+msgstr ""
+
+msgid "ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration"
+msgstr ""
+
msgid "Congratulations! You have enabled Two-factor Authentication!"
msgstr ""
@@ -6433,7 +6451,7 @@ msgstr ""
msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator."
msgstr ""
-msgid "ContainerRegistry|The value of this input should be less than 255 characters"
+msgid "ContainerRegistry|The value of this input should be less than 256 characters"
msgstr ""
msgid "ContainerRegistry|There are no container images available in this group"
@@ -20549,6 +20567,9 @@ msgstr ""
msgid "SecurityReports|Add projects"
msgstr ""
+msgid "SecurityReports|Add projects to your group"
+msgstr ""
+
msgid "SecurityReports|Comment added to '%{vulnerabilityName}'"
msgstr ""
@@ -20621,7 +20642,7 @@ msgstr ""
msgid "SecurityReports|More information"
msgstr ""
-msgid "SecurityReports|No vulnerabilities found for dashboard"
+msgid "SecurityReports|No vulnerabilities found"
msgstr ""
msgid "SecurityReports|No vulnerabilities found for this group"
@@ -20672,12 +20693,18 @@ msgstr ""
msgid "SecurityReports|Severity"
msgstr ""
+msgid "SecurityReports|Sorry, your filter produced no results"
+msgstr ""
+
msgid "SecurityReports|Status"
msgstr ""
msgid "SecurityReports|The rating \"unknown\" indicates that the underlying scanner doesn’t contain or provide a severity rating."
msgstr ""
+msgid "SecurityReports|The security dashboard displays the latest security findings for projects you wish to monitor. Add projects to your group to view their vulnerabilities here."
+msgstr ""
+
msgid "SecurityReports|The security dashboard displays the latest security findings for projects you wish to monitor. Select \"Edit dashboard\" to add and remove projects."
msgstr ""
@@ -20711,6 +20738,9 @@ msgstr ""
msgid "SecurityReports|There was an error while generating the report."
msgstr ""
+msgid "SecurityReports|To widen your search, change or remove filters above"
+msgstr ""
+
msgid "SecurityReports|Unable to add %{invalidProjectsMessage}"
msgstr ""
@@ -20729,7 +20759,7 @@ msgstr ""
msgid "SecurityReports|While it's rare to have no vulnerabilities for your project, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly."
msgstr ""
-msgid "SecurityReports|While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly."
+msgid "SecurityReports|While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly."
msgstr ""
msgid "SecurityReports|Won't fix / Accept risk"
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index fd97f6abb85..1c9e122b07b 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -89,6 +89,12 @@ FactoryBot.define do
end
end
+ factory :confluence_service do
+ project
+ active { true }
+ confluence_url { 'https://example.atlassian.net/wiki' }
+ end
+
factory :bugzilla_service do
project
active { true }
diff --git a/spec/frontend/filtered_search/stores/recent_searches_store_spec.js b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js
index 56bb82ae941..320aaa99bcc 100644
--- a/spec/frontend/filtered_search/stores/recent_searches_store_spec.js
+++ b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js
@@ -44,6 +44,15 @@ describe('RecentSearchesStore', () => {
expect(store.state.recentSearches).toEqual(['baz', 'qux']);
});
+ it('handles non-string values', () => {
+ store.setRecentSearches(['foo ', { foo: 'bar' }, { foo: 'bar' }, ['foobar']]);
+
+ // 1. String values will be trimmed of leading/trailing spaces
+ // 2. Comparison will account for objects to remove duplicates
+ // 3. Old behaviour of handling string values stays as it is.
+ expect(store.state.recentSearches).toEqual(['foo', { foo: 'bar' }, ['foobar']]);
+ });
+
it('only keeps track of 5 items', () => {
store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']);
diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js
index 827759c4901..88e7a9fff36 100644
--- a/spec/frontend/ide/stores/actions/file_spec.js
+++ b/spec/frontend/ide/stores/actions/file_spec.js
@@ -51,35 +51,27 @@ describe('IDE store file actions', () => {
store.state.entries[localFile.path] = localFile;
});
- it('closes open files', done => {
- store
- .dispatch('closeFile', localFile)
- .then(() => {
- expect(localFile.opened).toBeFalsy();
- expect(localFile.active).toBeFalsy();
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- })
- .catch(done.fail);
+ it('closes open files', () => {
+ return store.dispatch('closeFile', localFile).then(() => {
+ expect(localFile.opened).toBeFalsy();
+ expect(localFile.active).toBeFalsy();
+ expect(store.state.openFiles.length).toBe(0);
+ });
});
- it('closes file even if file has changes', done => {
+ it('closes file even if file has changes', () => {
store.state.changedFiles.push(localFile);
- store
+ return store
.dispatch('closeFile', localFile)
.then(Vue.nextTick)
.then(() => {
expect(store.state.openFiles.length).toBe(0);
expect(store.state.changedFiles.length).toBe(1);
-
- done();
- })
- .catch(done.fail);
+ });
});
- it('closes file & opens next available file', done => {
+ it('closes file & opens next available file', () => {
const f = {
...file('newOpenFile'),
url: '/newOpenFile',
@@ -88,31 +80,23 @@ describe('IDE store file actions', () => {
store.state.openFiles.push(f);
store.state.entries[f.path] = f;
- store
+ return store
.dispatch('closeFile', localFile)
.then(Vue.nextTick)
.then(() => {
expect(router.push).toHaveBeenCalledWith(`/project${f.url}`);
-
- done();
- })
- .catch(done.fail);
+ });
});
- it('removes file if it pending', done => {
+ it('removes file if it pending', () => {
store.state.openFiles.push({
...localFile,
pending: true,
});
- store
- .dispatch('closeFile', localFile)
- .then(() => {
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- })
- .catch(done.fail);
+ return store.dispatch('closeFile', localFile).then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+ });
});
});
@@ -264,61 +248,48 @@ describe('IDE store file actions', () => {
);
});
- it('calls the service', done => {
- store
- .dispatch('getFileData', { path: localFile.path })
- .then(() => {
- expect(service.getFileData).toHaveBeenCalledWith(
- `${RELATIVE_URL_ROOT}/test/test/-/7297abc/${localFile.path}`,
- );
-
- done();
- })
- .catch(done.fail);
+ it('calls the service', () => {
+ return store.dispatch('getFileData', { path: localFile.path }).then(() => {
+ expect(service.getFileData).toHaveBeenCalledWith(
+ `${RELATIVE_URL_ROOT}/test/test/-/7297abc/${localFile.path}`,
+ );
+ });
});
- it('sets document title with the branchId', done => {
- store
- .dispatch('getFileData', { path: localFile.path })
- .then(() => {
- expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`);
- done();
- })
- .catch(done.fail);
+ it('sets document title with the branchId', () => {
+ return store.dispatch('getFileData', { path: localFile.path }).then(() => {
+ expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`);
+ });
});
- it('sets the file as active', done => {
- store
- .dispatch('getFileData', { path: localFile.path })
- .then(() => {
- expect(localFile.active).toBeTruthy();
-
- done();
- })
- .catch(done.fail);
+ it('sets the file as active', () => {
+ return store.dispatch('getFileData', { path: localFile.path }).then(() => {
+ expect(localFile.active).toBeTruthy();
+ });
});
- it('sets the file not as active if we pass makeFileActive false', done => {
- store
+ it('sets the file not as active if we pass makeFileActive false', () => {
+ return store
.dispatch('getFileData', { path: localFile.path, makeFileActive: false })
.then(() => {
expect(localFile.active).toBeFalsy();
-
- done();
- })
- .catch(done.fail);
+ });
});
- it('adds the file to open files', done => {
- store
- .dispatch('getFileData', { path: localFile.path })
+ it('does not update the page title with the path of the file if makeFileActive is false', () => {
+ document.title = 'dummy title';
+ return store
+ .dispatch('getFileData', { path: localFile.path, makeFileActive: false })
.then(() => {
- expect(store.state.openFiles.length).toBe(1);
- expect(store.state.openFiles[0].name).toBe(localFile.name);
+ expect(document.title).toBe(`dummy title`);
+ });
+ });
- done();
- })
- .catch(done.fail);
+ it('adds the file to open files', () => {
+ return store.dispatch('getFileData', { path: localFile.path }).then(() => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(localFile.name);
+ });
});
});
@@ -342,15 +313,10 @@ describe('IDE store file actions', () => {
);
});
- it('sets document title considering `prevPath` on a file', done => {
- store
- .dispatch('getFileData', { path: localFile.path })
- .then(() => {
- expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`);
-
- done();
- })
- .catch(done.fail);
+ it('sets document title considering `prevPath` on a file', () => {
+ return store.dispatch('getFileData', { path: localFile.path }).then(() => {
+ expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`);
+ });
});
});
@@ -397,29 +363,19 @@ describe('IDE store file actions', () => {
mock.onGet(/(.*)/).replyOnce(200, 'raw');
});
- it('calls getRawFileData service method', done => {
- store
- .dispatch('getRawFileData', { path: tmpFile.path })
- .then(() => {
- expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
-
- done();
- })
- .catch(done.fail);
+ it('calls getRawFileData service method', () => {
+ return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => {
+ expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
+ });
});
- it('updates file raw data', done => {
- store
- .dispatch('getRawFileData', { path: tmpFile.path })
- .then(() => {
- expect(tmpFile.raw).toBe('raw');
-
- done();
- })
- .catch(done.fail);
+ it('updates file raw data', () => {
+ return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => {
+ expect(tmpFile.raw).toBe('raw');
+ });
});
- it('calls also getBaseRawFileData service method', done => {
+ it('calls also getBaseRawFileData service method', () => {
jest.spyOn(service, 'getBaseRawFileData').mockReturnValue(Promise.resolve('baseraw'));
store.state.currentProjectId = 'gitlab-org/gitlab-ce';
@@ -436,15 +392,10 @@ describe('IDE store file actions', () => {
tmpFile.mrChange = { new_file: false };
- store
- .dispatch('getRawFileData', { path: tmpFile.path })
- .then(() => {
- expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA');
- expect(tmpFile.baseRaw).toBe('baseraw');
-
- done();
- })
- .catch(done.fail);
+ return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => {
+ expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA');
+ expect(tmpFile.baseRaw).toBe('baseraw');
+ });
});
describe('sets file loading to true', () => {
@@ -501,15 +452,10 @@ describe('IDE store file actions', () => {
mock.onGet(/(.*)/).replyOnce(200, JSON.stringify({ test: '123' }));
});
- it('does not parse returned JSON', done => {
- store
- .dispatch('getRawFileData', { path: tmpFile.path })
- .then(() => {
- expect(tmpFile.raw).toEqual('{"test":"123"}');
-
- done();
- })
- .catch(done.fail);
+ it('does not parse returned JSON', () => {
+ return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => {
+ expect(tmpFile.raw).toEqual('{"test":"123"}');
+ });
});
});
@@ -558,32 +504,25 @@ describe('IDE store file actions', () => {
store.state.entries[tmpFile.path] = tmpFile;
});
- it('updates file content', done => {
- callAction()
- .then(() => {
- expect(tmpFile.content).toBe('content\n');
-
- done();
- })
- .catch(done.fail);
+ it('updates file content', () => {
+ return callAction().then(() => {
+ expect(tmpFile.content).toBe('content\n');
+ });
});
- it('adds file into stagedFiles array', done => {
- store
+ it('adds file into stagedFiles array', () => {
+ return store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() => {
expect(store.state.stagedFiles.length).toBe(1);
-
- done();
- })
- .catch(done.fail);
+ });
});
- it('adds file not more than once into stagedFiles array', done => {
- store
+ it('adds file not more than once into stagedFiles array', () => {
+ return store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
@@ -596,14 +535,11 @@ describe('IDE store file actions', () => {
)
.then(() => {
expect(store.state.stagedFiles.length).toBe(1);
-
- done();
- })
- .catch(done.fail);
+ });
});
- it('removes file from changedFiles array if not changed', done => {
- store
+ it('removes file from changedFiles array if not changed', () => {
+ return store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content\n',
@@ -616,10 +552,7 @@ describe('IDE store file actions', () => {
)
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
-
- done();
- })
- .catch(done.fail);
+ });
});
});
@@ -777,52 +710,36 @@ describe('IDE store file actions', () => {
store.state.entries[f.path] = f;
});
- it('makes file pending in openFiles', done => {
- store
- .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
- .then(() => {
- expect(store.state.openFiles[0].pending).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ it('makes file pending in openFiles', () => {
+ return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => {
+ expect(store.state.openFiles[0].pending).toBe(true);
+ });
});
- it('returns true when opened', done => {
- store
- .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
- .then(added => {
- expect(added).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ it('returns true when opened', () => {
+ return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(added => {
+ expect(added).toBe(true);
+ });
});
- it('returns false when already opened', done => {
+ it('returns false when already opened', () => {
store.state.openFiles.push({
...f,
active: true,
key: `pending-${f.key}`,
});
- store
- .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
- .then(added => {
- expect(added).toBe(false);
- })
- .then(done)
- .catch(done.fail);
+ return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(added => {
+ expect(added).toBe(false);
+ });
});
- it('pushes router URL when added', done => {
+ it('pushes router URL when added', () => {
store.state.currentBranchId = 'master';
- store
- .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
- .then(() => {
- expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/');
- })
- .then(done)
- .catch(done.fail);
+ return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => {
+ expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/');
+ });
});
});
@@ -838,26 +755,18 @@ describe('IDE store file actions', () => {
};
});
- it('removes pending file from open files', done => {
+ it('removes pending file from open files', () => {
store.state.openFiles.push(f);
- store
- .dispatch('removePendingTab', f)
- .then(() => {
- expect(store.state.openFiles.length).toBe(0);
- })
- .then(done)
- .catch(done.fail);
+ return store.dispatch('removePendingTab', f).then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+ });
});
- it('emits event to dispose model', done => {
- store
- .dispatch('removePendingTab', f)
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`);
- })
- .then(done)
- .catch(done.fail);
+ it('emits event to dispose model', () => {
+ return store.dispatch('removePendingTab', f).then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`);
+ });
});
});
@@ -866,14 +775,10 @@ describe('IDE store file actions', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
- it('emits event that files have changed', done => {
- store
- .dispatch('triggerFilesChange')
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change');
- })
- .then(done)
- .catch(done.fail);
+ it('emits event that files have changed', () => {
+ return store.dispatch('triggerFilesChange').then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change');
+ });
});
});
});
diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js
index 4178d3f0d2d..15a52d03bcd 100644
--- a/spec/frontend/monitoring/components/charts/anomaly_spec.js
+++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js
@@ -3,28 +3,14 @@ import { TEST_HOST } from 'helpers/test_constants';
import Anomaly from '~/monitoring/components/charts/anomaly.vue';
import { colorValues } from '~/monitoring/constants';
-import {
- anomalyDeploymentData,
- mockProjectDir,
- anomalyMockGraphData,
- anomalyMockResultValues,
-} from '../../mock_data';
+import { anomalyDeploymentData, mockProjectDir } from '../../mock_data';
+import { anomalyGraphData } from '../../graph_data';
import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
const mockProjectPath = `${TEST_HOST}${mockProjectDir}`;
-const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => {
- const metrics = anomalyMockResultValues[datasetName].map((values, index) => ({
- ...template.metrics[index],
- result: [
- {
- metrics: {},
- values,
- },
- ],
- }));
- return { ...template, metrics };
-};
+const TEST_UPPER = 11;
+const TEST_LOWER = 9;
describe('Anomaly chart component', () => {
let wrapper;
@@ -38,13 +24,22 @@ describe('Anomaly chart component', () => {
const getTimeSeriesProps = () => findTimeSeries().props();
describe('wrapped monitor-time-series-chart component', () => {
- const dataSetName = 'noAnomaly';
- const dataSet = anomalyMockResultValues[dataSetName];
+ const mockValues = ['10', '10', '10'];
+
+ const mockGraphData = anomalyGraphData(
+ {},
+ {
+ upper: mockValues.map(() => String(TEST_UPPER)),
+ values: mockValues,
+ lower: mockValues.map(() => String(TEST_LOWER)),
+ },
+ );
+
const inputThresholds = ['some threshold'];
beforeEach(() => {
setupAnomalyChart({
- graphData: makeAnomalyGraphData(dataSetName),
+ graphData: mockGraphData,
deploymentData: anomalyDeploymentData,
thresholds: inputThresholds,
projectPath: mockProjectPath,
@@ -65,21 +60,21 @@ describe('Anomaly chart component', () => {
it('receives "metric" with all data', () => {
const { graphData } = getTimeSeriesProps();
- const query = graphData.metrics[0];
- const expectedQuery = makeAnomalyGraphData(dataSetName).metrics[0];
- expect(query).toEqual(expectedQuery);
+ const metric = graphData.metrics[0];
+ const expectedMetric = mockGraphData.metrics[0];
+ expect(metric).toEqual(expectedMetric);
});
it('receives the "metric" results', () => {
const { graphData } = getTimeSeriesProps();
const { result } = graphData.metrics[0];
const { values } = result[0];
- const [metricDataset] = dataSet;
- expect(values).toEqual(expect.any(Array));
- values.forEach(([, y], index) => {
- expect(y).toBeCloseTo(metricDataset[index][1]);
- });
+ expect(values).toEqual([
+ [expect.any(String), 10],
+ [expect.any(String), 10],
+ [expect.any(String), 10],
+ ]);
});
});
@@ -108,14 +103,13 @@ describe('Anomaly chart component', () => {
it('upper boundary values are stacked on top of lower boundary', () => {
const [lowerSeries, upperSeries] = series;
- const [, upperDataset, lowerDataset] = dataSet;
- lowerSeries.data.forEach(([, y], i) => {
- expect(y).toBeCloseTo(lowerDataset[i][1]);
+ lowerSeries.data.forEach(([, y]) => {
+ expect(y).toBeCloseTo(TEST_LOWER);
});
- upperSeries.data.forEach(([, y], i) => {
- expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
+ upperSeries.data.forEach(([, y]) => {
+ expect(y).toBeCloseTo(TEST_UPPER - TEST_LOWER);
});
});
});
@@ -140,11 +134,10 @@ describe('Anomaly chart component', () => {
}),
);
});
+
it('does not display anomalies', () => {
const { symbolSize, itemStyle } = seriesConfig;
- const [metricDataset] = dataSet;
-
- metricDataset.forEach((v, dataIndex) => {
+ mockValues.forEach((v, dataIndex) => {
const size = symbolSize(null, { dataIndex });
const color = itemStyle.color({ dataIndex });
@@ -155,9 +148,10 @@ describe('Anomaly chart component', () => {
});
it('can format y values (to use in tooltips)', () => {
- expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
- expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]);
- expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]);
+ mockValues.forEach((v, dataIndex) => {
+ const formatted = wrapper.vm.yValueFormatted(0, dataIndex);
+ expect(parseFloat(formatted)).toEqual(parseFloat(v));
+ });
});
});
@@ -179,12 +173,18 @@ describe('Anomaly chart component', () => {
});
describe('with no boundary data', () => {
- const dataSetName = 'noBoundary';
- const dataSet = anomalyMockResultValues[dataSetName];
+ const noBoundaryData = anomalyGraphData(
+ {},
+ {
+ upper: [],
+ values: ['10', '10', '10'],
+ lower: [],
+ },
+ );
beforeEach(() => {
setupAnomalyChart({
- graphData: makeAnomalyGraphData(dataSetName),
+ graphData: noBoundaryData,
deploymentData: anomalyDeploymentData,
});
});
@@ -204,7 +204,7 @@ describe('Anomaly chart component', () => {
});
it('can format y values (to use in tooltips)', () => {
- expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
+ expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(10);
expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary
expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary
});
@@ -212,12 +212,20 @@ describe('Anomaly chart component', () => {
});
describe('with one anomaly', () => {
- const dataSetName = 'oneAnomaly';
- const dataSet = anomalyMockResultValues[dataSetName];
+ const mockValues = ['10', '20', '10'];
+
+ const oneAnomalyData = anomalyGraphData(
+ {},
+ {
+ upper: mockValues.map(() => TEST_UPPER),
+ values: mockValues,
+ lower: mockValues.map(() => TEST_LOWER),
+ },
+ );
beforeEach(() => {
setupAnomalyChart({
- graphData: makeAnomalyGraphData(dataSetName),
+ graphData: oneAnomalyData,
deploymentData: anomalyDeploymentData,
});
});
@@ -226,13 +234,12 @@ describe('Anomaly chart component', () => {
it('displays one anomaly', () => {
const { seriesConfig } = getTimeSeriesProps();
const { symbolSize, itemStyle } = seriesConfig;
- const [metricDataset] = dataSet;
- const bigDots = metricDataset.filter((v, dataIndex) => {
+ const bigDots = mockValues.filter((v, dataIndex) => {
const size = symbolSize(null, { dataIndex });
return size > 0.1;
});
- const redDots = metricDataset.filter((v, dataIndex) => {
+ const redDots = mockValues.filter((v, dataIndex) => {
const color = itemStyle.color({ dataIndex });
return color === colorValues.anomalySymbol;
});
@@ -244,13 +251,21 @@ describe('Anomaly chart component', () => {
});
describe('with offset', () => {
- const dataSetName = 'negativeBoundary';
- const dataSet = anomalyMockResultValues[dataSetName];
- const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded
+ const mockValues = ['10', '11', '12'];
+ const mockUpper = ['20', '20', '20'];
+ const mockLower = ['-1', '-2', '-3.70'];
+ const expectedOffset = 4; // Lowest point in mock data is -3.70, it gets rounded
beforeEach(() => {
setupAnomalyChart({
- graphData: makeAnomalyGraphData(dataSetName),
+ graphData: anomalyGraphData(
+ {},
+ {
+ upper: mockUpper,
+ values: mockValues,
+ lower: mockLower,
+ },
+ ),
deploymentData: anomalyDeploymentData,
});
});
@@ -266,11 +281,11 @@ describe('Anomaly chart component', () => {
const { graphData } = getTimeSeriesProps();
const { result } = graphData.metrics[0];
const { values } = result[0];
- const [metricDataset] = dataSet;
+
expect(values).toEqual(expect.any(Array));
values.forEach(([, y], index) => {
- expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset);
+ expect(y).toBeCloseTo(parseFloat(mockValues[index]) + expectedOffset);
});
});
});
@@ -281,14 +296,12 @@ describe('Anomaly chart component', () => {
const { option } = getTimeSeriesProps();
const { series } = option;
const [lowerSeries, upperSeries] = series;
- const [, upperDataset, lowerDataset] = dataSet;
-
lowerSeries.data.forEach(([, y], i) => {
- expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset);
+ expect(y).toBeCloseTo(parseFloat(mockLower[i]) + expectedOffset);
});
upperSeries.data.forEach(([, y], i) => {
- expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
+ expect(y).toBeCloseTo(parseFloat(mockUpper[i] - mockLower[i]));
});
});
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index d2a8ffb58e6..693818aa55a 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -9,7 +9,6 @@ import AlertWidget from '~/monitoring/components/alert_widget.vue';
import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import {
- anomalyMockGraphData,
mockLogsHref,
mockLogsPath,
mockNamespace,
@@ -19,7 +18,7 @@ import {
barMockData,
} from '../mock_data';
import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data';
-import { singleStatGraphData } from '../graph_data';
+import { anomalyGraphData, singleStatGraphData } from '../graph_data';
import { panelTypes } from '~/monitoring/constants';
@@ -233,7 +232,7 @@ describe('Dashboard Panel', () => {
${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} | ${true}
${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} | ${true}
${singleStatGraphData()} | ${MonitorSingleStatChart} | ${true}
- ${anomalyMockGraphData} | ${MonitorAnomalyChart} | ${false}
+ ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false}
${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false}
${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false}
${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} | ${false}
diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js
index 2973ecb8be7..e1b95723f3d 100644
--- a/spec/frontend/monitoring/graph_data.js
+++ b/spec/frontend/monitoring/graph_data.js
@@ -124,3 +124,41 @@ export const singleStatGraphData = (panelOptions = {}, dataOptions = {}) => {
...panelOptions,
});
};
+
+/**
+ * Generate mock graph data according to options
+ *
+ * @param {Object} panelOptions - Panel options as in YML.
+ * @param {Object} dataOptions
+ * @param {Array} dataOptions.values - Metric values
+ * @param {Array} dataOptions.upper - Upper boundary values
+ * @param {Array} dataOptions.lower - Lower boundary values
+ */
+export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => {
+ const { values, upper, lower } = dataOptions;
+
+ return mapPanelToViewModel({
+ title: 'Anomaly Panel',
+ type: panelTypes.ANOMALY_CHART,
+ x_label: 'X Axis',
+ y_label: 'Y Axis',
+ metrics: [
+ {
+ label: `Metric`,
+ state: metricStates.OK,
+ result: matrixSingleResult({ values }),
+ },
+ {
+ label: `Upper boundary`,
+ state: metricStates.OK,
+ result: matrixSingleResult({ values: upper }),
+ },
+ {
+ label: `Lower boundary`,
+ state: metricStates.OK,
+ result: matrixSingleResult({ values: lower }),
+ },
+ ],
+ ...panelOptions,
+ });
+};
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index 3d55fb7010b..49ad33402c6 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -51,136 +51,6 @@ export const anomalyDeploymentData = [
},
];
-export const anomalyMockResultValues = {
- noAnomaly: [
- [
- ['2019-08-19T19:00:00.000Z', 1.25],
- ['2019-08-19T20:00:00.000Z', 1.45],
- ['2019-08-19T21:00:00.000Z', 1.55],
- ['2019-08-19T22:00:00.000Z', 1.48],
- ],
- [
- // upper boundary
- ['2019-08-19T19:00:00.000Z', 2],
- ['2019-08-19T20:00:00.000Z', 2.55],
- ['2019-08-19T21:00:00.000Z', 2.65],
- ['2019-08-19T22:00:00.000Z', 3.0],
- ],
- [
- // lower boundary
- ['2019-08-19T19:00:00.000Z', 0.45],
- ['2019-08-19T20:00:00.000Z', 0.65],
- ['2019-08-19T21:00:00.000Z', 0.7],
- ['2019-08-19T22:00:00.000Z', 0.8],
- ],
- ],
- noBoundary: [
- [
- ['2019-08-19T19:00:00.000Z', 1.25],
- ['2019-08-19T20:00:00.000Z', 1.45],
- ['2019-08-19T21:00:00.000Z', 1.55],
- ['2019-08-19T22:00:00.000Z', 1.48],
- ],
- [
- // empty upper boundary
- ],
- [
- // empty lower boundary
- ],
- ],
- oneAnomaly: [
- [
- ['2019-08-19T19:00:00.000Z', 1.25],
- ['2019-08-19T20:00:00.000Z', 3.45], // anomaly
- ['2019-08-19T21:00:00.000Z', 1.55],
- ],
- [
- // upper boundary
- ['2019-08-19T19:00:00.000Z', 2],
- ['2019-08-19T20:00:00.000Z', 2.55],
- ['2019-08-19T21:00:00.000Z', 2.65],
- ],
- [
- // lower boundary
- ['2019-08-19T19:00:00.000Z', 0.45],
- ['2019-08-19T20:00:00.000Z', 0.65],
- ['2019-08-19T21:00:00.000Z', 0.7],
- ],
- ],
- negativeBoundary: [
- [
- ['2019-08-19T19:00:00.000Z', 1.25],
- ['2019-08-19T20:00:00.000Z', 3.45], // anomaly
- ['2019-08-19T21:00:00.000Z', 1.55],
- ],
- [
- // upper boundary
- ['2019-08-19T19:00:00.000Z', 2],
- ['2019-08-19T20:00:00.000Z', 2.55],
- ['2019-08-19T21:00:00.000Z', 2.65],
- ],
- [
- // lower boundary
- ['2019-08-19T19:00:00.000Z', -1.25],
- ['2019-08-19T20:00:00.000Z', -2.65],
- ['2019-08-19T21:00:00.000Z', -3.7], // lowest point
- ],
- ],
-};
-
-export const anomalyMockGraphData = {
- title: 'Requests Per Second Mock Data',
- type: 'anomaly-chart',
- weight: 3,
- metrics: [
- {
- metricId: '90',
- id: 'metric',
- query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE',
- unit: 'RPS',
- label: 'Metrics RPS',
- metric_id: 90,
- prometheus_endpoint_path: 'MOCK_METRIC_PEP',
- result: [
- {
- metric: {},
- values: [['2019-08-19T19:00:00.000Z', 0]],
- },
- ],
- },
- {
- metricId: '91',
- id: 'upper',
- query_range: '...',
- unit: 'RPS',
- label: 'Upper Limit Metrics RPS',
- metric_id: 91,
- prometheus_endpoint_path: 'MOCK_UPPER_PEP',
- result: [
- {
- metric: {},
- values: [['2019-08-19T19:00:00.000Z', 0]],
- },
- ],
- },
- {
- metricId: '92',
- id: 'lower',
- query_range: '...',
- unit: 'RPS',
- label: 'Lower Limit Metrics RPS',
- metric_id: 92,
- prometheus_endpoint_path: 'MOCK_LOWER_PEP',
- result: [
- {
- metric: {},
- values: [['2019-08-19T19:00:00.000Z', 0]],
- },
- ],
- },
- ],
-};
-
export const deploymentData = [
{
id: 111,
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
index d74a1f975ee..35ca6ba9b52 100644
--- a/spec/frontend/monitoring/utils_spec.js
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -1,9 +1,9 @@
import * as monitoringUtils from '~/monitoring/utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { TEST_HOST } from 'jest/helpers/test_constants';
-import { mockProjectDir, anomalyMockGraphData, barMockData } from './mock_data';
+import { mockProjectDir, barMockData } from './mock_data';
+import { singleStatGraphData, anomalyGraphData } from './graph_data';
import { metricsDashboardViewModel, graphData } from './fixture_data';
-import { singleStatGraphData } from './graph_data';
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
@@ -102,12 +102,12 @@ describe('monitoring/utils', () => {
let fourMetrics;
beforeEach(() => {
oneMetric = singleStatGraphData();
- threeMetrics = anomalyMockGraphData;
+ threeMetrics = anomalyGraphData();
const metrics = [...threeMetrics.metrics];
metrics.push(threeMetrics.metrics[0]);
fourMetrics = {
- ...anomalyMockGraphData,
+ ...anomalyGraphData(),
metrics,
};
});
diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js
index 2b3e529b283..9b9ca92270c 100644
--- a/spec/frontend/registry/settings/components/settings_form_spec.js
+++ b/spec/frontend/registry/settings/components/settings_form_spec.js
@@ -7,6 +7,7 @@ import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '~/registry/shared/constants';
+import waitForPromises from 'helpers/wait_for_promises';
import { stringifiedFormOptions } from '../../shared/mock_data';
describe('Settings Form', () => {
@@ -36,12 +37,17 @@ describe('Settings Form', () => {
const findSaveButton = () => wrapper.find({ ref: 'save-button' });
const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon);
- const mountComponent = () => {
+ const mountComponent = (data = {}) => {
wrapper = shallowMount(component, {
stubs: {
GlCard,
GlLoadingIcon,
},
+ data() {
+ return {
+ ...data,
+ };
+ },
mocks: {
$toast: {
show: jest.fn(),
@@ -55,7 +61,6 @@ describe('Settings Form', () => {
store = createStore();
store.dispatch('setInitialState', stringifiedFormOptions);
dispatchSpy = jest.spyOn(store, 'dispatch');
- mountComponent();
jest.spyOn(Tracking, 'event');
});
@@ -63,20 +68,30 @@ describe('Settings Form', () => {
wrapper.destroy();
});
+ describe('data binding', () => {
+ it('v-model change update the settings property', () => {
+ mountComponent();
+ findFields().vm.$emit('input', { newValue: 'foo' });
+ expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' });
+ });
+
+ it('v-model change update the api error property', () => {
+ const apiErrors = { baz: 'bar' };
+ mountComponent({ apiErrors });
+ expect(findFields().props('apiErrors')).toEqual(apiErrors);
+ findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' });
+ expect(findFields().props('apiErrors')).toEqual({});
+ });
+ });
+
describe('form', () => {
let form;
beforeEach(() => {
+ mountComponent();
form = findForm();
dispatchSpy.mockReturnValue();
});
- describe('data binding', () => {
- it('v-model change update the settings property', () => {
- findFields().vm.$emit('input', 'foo');
- expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' });
- });
- });
-
describe('form reset event', () => {
beforeEach(() => {
form.trigger('reset');
@@ -108,24 +123,40 @@ describe('Settings Form', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
});
- it('show a success toast when submit succeed', () => {
+ it('show a success toast when submit succeed', async () => {
dispatchSpy.mockResolvedValue();
form.trigger('submit');
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, {
- type: 'success',
- });
+ await waitForPromises();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, {
+ type: 'success',
});
});
- it('show an error toast when submit fails', () => {
- dispatchSpy.mockRejectedValue();
- form.trigger('submit');
- return wrapper.vm.$nextTick().then(() => {
+ describe('when submit fails', () => {
+ it('shows an error', async () => {
+ dispatchSpy.mockRejectedValue({ response: {} });
+ form.trigger('submit');
+ await waitForPromises();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {
type: 'error',
});
});
+
+ it('parses the error messages', async () => {
+ dispatchSpy.mockRejectedValue({
+ response: {
+ data: {
+ message: {
+ foo: 'bar',
+ 'container_expiration_policy.name': ['baz'],
+ },
+ },
+ },
+ });
+ form.trigger('submit');
+ await waitForPromises();
+ expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
+ });
});
});
});
@@ -134,6 +165,7 @@ describe('Settings Form', () => {
describe('cancel button', () => {
beforeEach(() => {
store.commit('SET_SETTINGS', { foo: 'bar' });
+ mountComponent();
});
it('has type reset', () => {
@@ -165,6 +197,7 @@ describe('Settings Form', () => {
describe('when isLoading is true', () => {
beforeEach(() => {
store.commit('TOGGLE_LOADING');
+ mountComponent();
});
afterEach(() => {
store.commit('TOGGLE_LOADING');
diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
index 9f30ed43093..882f1b3211a 100644
--- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
+++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
@@ -114,7 +114,6 @@ exports[`Expiration Policy Form renders 1`] = `
<gl-form-group-stub
id="expiration-policy-name-matching-group"
- invalid-feedback="The value of this input should be less than 255 characters"
label-align="right"
label-cols="3"
label-for="expiration-policy-name-matching"
@@ -131,7 +130,6 @@ exports[`Expiration Policy Form renders 1`] = `
</gl-form-group-stub>
<gl-form-group-stub
id="expiration-policy-keep-name-group"
- invalid-feedback="The value of this input should be less than 255 characters"
label-align="right"
label-cols="3"
label-for="expiration-policy-keep-name"
diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
index d9889dc534c..ee765ffd1c0 100644
--- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
+++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
@@ -94,7 +94,9 @@ describe('Expiration Policy Form', () => {
: 'input';
element.vm.$emit(modelUpdateEvent, value);
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('input')).toEqual([[{ [modelName]: value }]]);
+ expect(wrapper.emitted('input')).toEqual([
+ [{ newValue: { [modelName]: value }, modified: modelName }],
+ ]);
});
});
@@ -126,42 +128,61 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- modelName | elementName | stateVariable
- ${'name_regex'} | ${'name-matching'} | ${'nameRegexState'}
- ${'name_regex_keep'} | ${'keep-name'} | ${'nameKeepRegexState'}
- `('regex textarea validation', ({ modelName, elementName, stateVariable }) => {
- describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
- const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
-
- beforeEach(() => {
- mountComponent({ value: { [modelName]: invalidString } });
+ modelName | elementName
+ ${'name_regex'} | ${'name-matching'}
+ ${'name_regex_keep'} | ${'keep-name'}
+ `('regex textarea validation', ({ modelName, elementName }) => {
+ const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
+
+ describe('when apiError contains an error message', () => {
+ const errorMessage = 'something went wrong';
+
+ it('shows the error message on the relevant field', () => {
+ mountComponent({ apiErrors: { [modelName]: errorMessage } });
+ expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage);
});
- it(`${stateVariable} is false`, () => {
- expect(wrapper.vm.textAreaState[stateVariable]).toBe(false);
- });
-
- it('emit the @invalidated event', () => {
- expect(wrapper.emitted('invalidated')).toBeTruthy();
+ it('gives precedence to API errors compared to local ones', () => {
+ mountComponent({
+ apiErrors: { [modelName]: errorMessage },
+ value: { [modelName]: invalidString },
+ });
+ expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage);
});
});
- it('if the user did not type validation is null', () => {
- mountComponent({ value: { [modelName]: '' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.textAreaState[stateVariable]).toBe(null);
+ describe('when apiErrors is empty', () => {
+ it('if the user did not type validation is null', async () => {
+ mountComponent({ value: { [modelName]: '' } });
+ expect(findFormGroup(elementName).attributes('state')).toBeUndefined();
expect(wrapper.emitted('validated')).toBeTruthy();
});
- });
- it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => {
- mountComponent({ value: { [modelName]: 'foo' } });
- return wrapper.vm.$nextTick().then(() => {
+ it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => {
+ mountComponent({ value: { [modelName]: 'foo' } });
+
const formGroup = findFormGroup(elementName);
const formElement = findFormElements(elementName, formGroup);
expect(formGroup.attributes('state')).toBeTruthy();
expect(formElement.attributes('state')).toBeTruthy();
});
+
+ describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
+ beforeEach(() => {
+ mountComponent({ value: { [modelName]: invalidString } });
+ });
+
+ it('textAreaValidation state is false', () => {
+ expect(findFormGroup(elementName).attributes('state')).toBeUndefined();
+ // we are forced to check the model attribute because falsy attrs are all casted to undefined in attrs
+ // while in this case false shows an error and null instead shows nothing.
+ expect(wrapper.vm.textAreaValidation[modelName].state).toBe(false);
+ });
+
+ it('emit the @invalidated event', () => {
+ expect(wrapper.emitted('invalidated')).toBeTruthy();
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index eded5b87abc..92ef20aad6c 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
-import { mockAvailableTokens, mockSortOptions } from './mock_data';
+import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
const createComponent = ({
namespace = 'gitlab-org/gitlab-test',
@@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => {
describe('computed', () => {
describe('tokenSymbols', () => {
- it('returns array of map containing type and symbols from `tokens` prop', () => {
+ it('returns a map containing type and symbols from `tokens` prop', () => {
expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' });
});
});
+ describe('tokenTitles', () => {
+ it('returns a map containing type and title from `tokens` prop', () => {
+ expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' });
+ });
+ });
+
describe('sortDirectionIcon', () => {
it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => {
wrapper.setData({
@@ -172,6 +178,19 @@ describe('FilteredSearchBarRoot', () => {
});
});
+ describe('handleClearHistory', () => {
+ it('clears search history from recent searches store', () => {
+ jest.spyOn(wrapper.vm.recentSearchesStore, 'setRecentSearches').mockReturnValue([]);
+ jest.spyOn(wrapper.vm.recentSearchesService, 'save');
+
+ wrapper.vm.handleClearHistory();
+
+ expect(wrapper.vm.recentSearchesStore.setRecentSearches).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.getRecentSearches()).toEqual([]);
+ });
+ });
+
describe('handleFilterSubmit', () => {
const mockFilters = [
{
@@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => {
it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => {
jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch');
- // jest.spyOn(wrapper.vm.recentSearchesService, 'save');
wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => {
- expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(
- 'author_username:=@root foo',
- );
+ expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters);
});
});
@@ -203,9 +219,7 @@ describe('FilteredSearchBarRoot', () => {
wrapper.vm.handleFilterSubmit(mockFilters);
return wrapper.vm.recentSearchesPromise.then(() => {
- expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([
- 'author_username:=@root foo',
- ]);
+ expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]);
});
});
@@ -224,6 +238,8 @@ describe('FilteredSearchBarRoot', () => {
selectedSortDirection: SortDirection.descending,
});
+ wrapper.vm.recentSearchesStore.setRecentSearches(mockHistoryItems);
+
return wrapper.vm.$nextTick();
});
@@ -232,6 +248,7 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens);
+ expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
it('renders sort dropdown component', () => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index edc0f119262..7e28c4e11e1 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -44,6 +44,29 @@ export const mockAuthorToken = {
export const mockAvailableTokens = [mockAuthorToken];
+export const mockHistoryItems = [
+ [
+ {
+ type: 'author_username',
+ value: {
+ data: 'toby',
+ operator: '=',
+ },
+ },
+ 'duo',
+ ],
+ [
+ {
+ type: 'author_username',
+ value: {
+ data: 'root',
+ operator: '=',
+ },
+ },
+ 'si',
+ ],
+];
+
export const mockSortOptions = [
{
id: 1,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index 3650ef79136..45294096eda 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data';
jest.mock('~/flash');
-const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) =>
+const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) =>
mount(AuthorToken, {
propsData: {
config,
value,
+ active,
},
provide: {
portalName: 'fake target',
@@ -51,29 +52,23 @@ describe('AuthorToken', () => {
describe('computed', () => {
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
- wrapper.setProps({
- value: { data: 'FOO' },
- });
+ wrapper = createComponent({ value: { data: 'FOO' } });
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.currentValue).toBe('foo');
- });
+ expect(wrapper.vm.currentValue).toBe('foo');
});
});
describe('activeAuthor', () => {
- it('returns object for currently present `value.data`', () => {
+ it('returns object for currently present `value.data`', async () => {
+ wrapper = createComponent({ value: { data: mockAuthors[0].username } });
+
wrapper.setData({
authors: mockAuthors,
});
- wrapper.setProps({
- value: { data: mockAuthors[0].username },
- });
+ await wrapper.vm.$nextTick();
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
- });
+ expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]);
});
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
index 244e37f18c6..2253db7cbd0 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
@@ -2,14 +2,17 @@ import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
buildUneditableCloseTokens,
+ buildUneditableInlineTokens,
buildUneditableTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import {
+ originInlineToken,
originToken,
uneditableOpenTokens,
uneditableCloseToken,
uneditableCloseTokens,
+ uneditableInlineTokens,
uneditableTokens,
} from './mock_data';
@@ -38,8 +41,17 @@ describe('Build Uneditable Token renderer helper', () => {
});
});
+ describe('buildUneditableInlineTokens', () => {
+ it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => {
+ const result = buildUneditableInlineTokens(originInlineToken);
+
+ expect(result).toHaveLength(3);
+ expect(result).toStrictEqual(uneditableInlineTokens);
+ });
+ });
+
describe('buildUneditableTokens', () => {
- it('returns a 3-item array of tokens with the originToken wrapped in the middle', () => {
+ it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => {
const result = buildUneditableTokens(originToken);
expect(result).toHaveLength(3);
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
index e81d9f55c05..0c010a20d98 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
@@ -12,20 +12,36 @@ export const normalTextNode = buildMockTextNode('This is just normal text.');
// Token spec helpers
-const uneditableOpenToken = {
- type: 'openTag',
- tagName: 'div',
- attributes: { contenteditable: false },
- classNames: [
- 'gl-px-4 gl-py-2 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
- ],
+const buildUneditableOpenToken = type => {
+ return {
+ type: 'openTag',
+ tagName: type,
+ attributes: { contenteditable: false },
+ classNames: [
+ 'gl-px-4 gl-py-2 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
+ ],
+ };
+};
+
+const buildUneditableCloseToken = type => {
+ return { type: 'closeTag', tagName: type };
};
-export const uneditableCloseToken = { type: 'closeTag', tagName: 'div' };
export const originToken = {
type: 'text',
content: '{:.no_toc .hidden-md .hidden-lg}',
};
-export const uneditableOpenTokens = [uneditableOpenToken, originToken];
+export const uneditableCloseToken = buildUneditableCloseToken('div');
+export const uneditableOpenTokens = [buildUneditableOpenToken('div'), originToken];
export const uneditableCloseTokens = [originToken, uneditableCloseToken];
export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken];
+
+export const originInlineToken = {
+ type: 'text',
+ content: '<i>Inline</i> content',
+};
+export const uneditableInlineTokens = [
+ buildUneditableOpenToken('span'),
+ originInlineToken,
+ buildUneditableCloseToken('span'),
+];
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
new file mode 100644
index 00000000000..d6bb01259bb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
@@ -0,0 +1,33 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline';
+import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { normalTextNode } from './mock_data';
+
+const fontAwesomeInlineHtmlNode = {
+ firstChild: null,
+ literal: '<i class="far fa-paper-plane" id="biz-tech-icons">',
+ type: 'html',
+};
+
+describe('Render Font Awesome Inline HTML renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument `literal` has font awesome inline html syntax', () => {
+ expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true);
+ });
+
+ it('should return false when the argument `literal` lacks font awesome inline html syntax', () => {
+ expect(renderer.canRender(normalTextNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ it('should return uneditable inline tokens', () => {
+ const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal };
+ const context = { origin: () => token };
+
+ expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual(
+ buildUneditableInlineTokens(token),
+ );
+ });
+ });
+});
diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb
index e2e6659382a..c47bba42ae2 100644
--- a/spec/helpers/icons_helper_spec.rb
+++ b/spec/helpers/icons_helper_spec.rb
@@ -22,8 +22,13 @@ RSpec.describe IconsHelper do
describe 'sprite_icon_path' do
it 'returns relative path' do
- expect(sprite_icon_path)
- .to eq icons_path
+ expect(sprite_icon_path).to eq(icons_path)
+ end
+
+ it 'only calls image_path once when called multiple times' do
+ expect(ActionController::Base.helpers).to receive(:image_path).once.and_call_original
+
+ 2.times { sprite_icon_path }
end
context 'when an asset_host is set in the config it will return an absolute local URL' do
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 221825841b8..a3d0673f1b3 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -444,8 +444,8 @@ RSpec.describe ProjectsHelper do
end
describe '#get_project_nav_tabs' do
+ let_it_be(:user) { create(:user) }
let(:project) { create(:project) }
- let(:user) { create(:user) }
before do
allow(helper).to receive(:can?) { true }
@@ -501,6 +501,20 @@ RSpec.describe ProjectsHelper do
is_expected.not_to include(:external_wiki)
end
end
+
+ context 'when project has confluence enabled' do
+ before do
+ allow(project).to receive(:has_confluence?).and_return(true)
+ end
+
+ it { is_expected.to include(:confluence) }
+ it { is_expected.not_to include(:wiki) }
+ end
+
+ context 'when project does not have confluence enabled' do
+ it { is_expected.not_to include(:confluence) }
+ it { is_expected.to include(:wiki) }
+ end
end
describe '#can_view_operations_tab?' do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 2ec0433c5d0..32be6d1eb7c 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -322,6 +322,7 @@ project:
- last_event
- services
- campfire_service
+- confluence_service
- discord_service
- drone_ci_service
- emails_on_push_service
diff --git a/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb b/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb
new file mode 100644
index 00000000000..31dee29a39b
--- /dev/null
+++ b/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200701070435_add_default_value_stream_to_groups_with_group_stages.rb')
+
+RSpec.describe AddDefaultValueStreamToGroupsWithGroupStages, schema: 20200624142207 do
+ let(:groups) { table(:namespaces) }
+ let(:group_stages) { table(:analytics_cycle_analytics_group_stages) }
+ let(:value_streams) { table(:analytics_cycle_analytics_group_value_streams) }
+
+ let!(:group) { groups.create!(name: 'test', path: 'path', type: 'Group') }
+ let!(:group_stage) { group_stages.create!(name: 'test', group_id: group.id, start_event_identifier: 1, end_event_identifier: 2) }
+
+ describe '#up' do
+ it 'creates default value stream record for the group' do
+ migrate!
+
+ group_value_streams = value_streams.where(group_id: group.id)
+ expect(group_value_streams.size).to eq(1)
+
+ value_stream = group_value_streams.first
+ expect(value_stream.name).to eq('default')
+ end
+
+ it 'migrates existing stages to the default value stream' do
+ migrate!
+
+ group_stage.reload
+
+ value_stream = value_streams.find_by(group_id: group.id, name: 'default')
+ expect(group_stage.group_value_stream_id).to eq(value_stream.id)
+ end
+ end
+
+ describe '#down' do
+ it 'sets the group_value_stream_id to nil' do
+ described_class.new.down
+
+ group_stage.reload
+
+ expect(group_stage.group_value_stream_id).to be_nil
+ end
+ end
+end
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 21a1b3fda75..41693b2a084 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -174,18 +174,6 @@ RSpec.describe Ci::JobArtifact do
end
end
- describe '.for_ref' do
- let(:first_pipeline) { create(:ci_pipeline, ref: 'first_ref') }
- let(:second_pipeline) { create(:ci_pipeline, ref: 'second_ref', project: first_pipeline.project) }
- let!(:first_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) }
- let!(:second_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) }
-
- it 'returns job artifacts for a given pipeline ref' do
- expect(described_class.for_ref(first_pipeline.ref, first_pipeline.project.id)).to eq([first_artifact])
- expect(described_class.for_ref(second_pipeline.ref, first_pipeline.project.id)).to eq([second_artifact])
- end
- end
-
describe '.for_job_name' do
it 'returns job artifacts for a given job name' do
first_job = create(:ci_build, name: 'first')
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 3a5df241504..e6a92c4d8d5 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -219,6 +219,50 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
+ describe '.outside_pipeline_family' do
+ subject(:outside_pipeline_family) { described_class.outside_pipeline_family(upstream_pipeline) }
+
+ let(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let(:child_pipeline) { create(:ci_pipeline, project: project) }
+
+ let!(:other_pipeline) { create(:ci_pipeline, project: project) }
+
+ before do
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: upstream_pipeline),
+ source_project: project,
+ pipeline: child_pipeline,
+ project: project)
+ end
+
+ it 'only returns pipelines outside pipeline family' do
+ expect(outside_pipeline_family).to contain_exactly(other_pipeline)
+ end
+ end
+
+ describe '.before_pipeline' do
+ subject(:before_pipeline) { described_class.before_pipeline(child_pipeline) }
+
+ let!(:older_other_pipeline) { create(:ci_pipeline, project: project) }
+
+ let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:child_pipeline) { create(:ci_pipeline, project: project) }
+
+ let!(:other_pipeline) { create(:ci_pipeline, project: project) }
+
+ before do
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: upstream_pipeline),
+ source_project: project,
+ pipeline: child_pipeline,
+ project: project)
+ end
+
+ it 'only returns older pipelines outside pipeline family' do
+ expect(before_pipeline).to contain_exactly(older_other_pipeline)
+ end
+ end
+
describe '#merge_request?' do
let(:pipeline) { create(:ci_pipeline, merge_request: merge_request) }
let(:merge_request) { create(:merge_request) }
@@ -2635,6 +2679,55 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
+ describe '#same_family_pipeline_ids' do
+ subject(:same_family_pipeline_ids) { pipeline.same_family_pipeline_ids }
+
+ context 'when pipeline is not child nor parent' do
+ it 'returns just the pipeline id' do
+ expect(same_family_pipeline_ids).to contain_exactly(pipeline)
+ end
+ end
+
+ context 'when pipeline is child' do
+ let(:parent) { create(:ci_pipeline, project: pipeline.project) }
+ let(:sibling) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: parent),
+ source_project: parent.project,
+ pipeline: pipeline,
+ project: pipeline.project)
+
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: parent),
+ source_project: parent.project,
+ pipeline: sibling,
+ project: sibling.project)
+ end
+
+ it 'returns parent sibling and self ids' do
+ expect(same_family_pipeline_ids).to contain_exactly(parent, pipeline, sibling)
+ end
+ end
+
+ context 'when pipeline is parent' do
+ let(:child) { create(:ci_pipeline, project: pipeline.project) }
+
+ before do
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: pipeline),
+ source_project: pipeline.project,
+ pipeline: child,
+ project: child.project)
+ end
+
+ it 'returns self and child ids' do
+ expect(same_family_pipeline_ids).to contain_exactly(pipeline, child)
+ end
+ end
+ end
+
describe '#stuck?' do
before do
create(:ci_build, :pending, pipeline: pipeline)
@@ -3179,6 +3272,32 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
end
+
+ context 'when transitioning to success' do
+ context 'when feature is enabled' do
+ before do
+ stub_feature_flags(keep_latest_artifacts_for_ref: true)
+ end
+
+ it 'calls the PipelineSuccessUnlockArtifactsWorker' do
+ expect(Ci::PipelineSuccessUnlockArtifactsWorker).to receive(:perform_async).with(pipeline.id)
+
+ pipeline.succeed!
+ end
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(keep_latest_artifacts_for_ref: false)
+ end
+
+ it 'does not call the PipelineSuccessUnlockArtifactsWorker' do
+ expect(Ci::PipelineSuccessUnlockArtifactsWorker).not_to receive(:perform_async)
+
+ pipeline.succeed!
+ end
+ end
+ end
end
describe '#default_branch?' do
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 3c739771b19..9863a4051f4 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1248,51 +1248,21 @@ RSpec.describe MergeRequest do
let(:merge_request) { subject }
let(:repository) { merge_request.source_project.repository }
- context 'when memoize_source_branch_merge_request feature is enabled' do
- before do
- stub_feature_flags(memoize_source_branch_merge_request: true)
- end
-
- context 'when the source project is set' do
- it 'memoizes the value and returns the result' do
- expect(repository).to receive(:branch_exists?).once.with(merge_request.source_branch).and_return(true)
-
- 2.times { expect(merge_request.source_branch_exists?).to eq(true) }
- end
- end
+ context 'when the source project is set' do
+ it 'memoizes the value and returns the result' do
+ expect(repository).to receive(:branch_exists?).once.with(merge_request.source_branch).and_return(true)
- context 'when the source project is not set' do
- before do
- merge_request.source_project = nil
- end
-
- it 'returns false' do
- expect(merge_request.source_branch_exists?).to eq(false)
- end
+ 2.times { expect(merge_request.source_branch_exists?).to eq(true) }
end
end
- context 'when memoize_source_branch_merge_request feature is disabled' do
+ context 'when the source project is not set' do
before do
- stub_feature_flags(memoize_source_branch_merge_request: false)
+ merge_request.source_project = nil
end
- context 'when the source project is set' do
- it 'does not memoize the value and returns the result' do
- expect(repository).to receive(:branch_exists?).twice.with(merge_request.source_branch).and_return(true)
-
- 2.times { expect(merge_request.source_branch_exists?).to eq(true) }
- end
- end
-
- context 'when the source project is not set' do
- before do
- merge_request.source_project = nil
- end
-
- it 'returns false' do
- expect(merge_request.source_branch_exists?).to eq(false)
- end
+ it 'returns false' do
+ expect(merge_request.source_branch_exists?).to eq(false)
end
end
end
diff --git a/spec/models/project_services/confluence_service_spec.rb b/spec/models/project_services/confluence_service_spec.rb
new file mode 100644
index 00000000000..5d153b17070
--- /dev/null
+++ b/spec/models/project_services/confluence_service_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ConfluenceService do
+ describe 'Associations' do
+ it { is_expected.to belong_to :project }
+ it { is_expected.to have_one :service_hook }
+ end
+
+ describe 'Validations' do
+ before do
+ subject.active = active
+ end
+
+ context 'when service is active' do
+ let(:active) { true }
+
+ it { is_expected.not_to allow_value('https://example.com').for(:confluence_url) }
+ it { is_expected.not_to allow_value('example.com').for(:confluence_url) }
+ it { is_expected.not_to allow_value('foo').for(:confluence_url) }
+ it { is_expected.not_to allow_value('ftp://example.atlassian.net/wiki').for(:confluence_url) }
+ it { is_expected.not_to allow_value('https://example.atlassian.net').for(:confluence_url) }
+ it { is_expected.not_to allow_value('https://.atlassian.net/wiki').for(:confluence_url) }
+ it { is_expected.not_to allow_value('https://example.atlassian.net/wikifoo').for(:confluence_url) }
+ it { is_expected.not_to allow_value('').for(:confluence_url) }
+ it { is_expected.not_to allow_value(nil).for(:confluence_url) }
+ it { is_expected.not_to allow_value('😊').for(:confluence_url) }
+ it { is_expected.to allow_value('https://example.atlassian.net/wiki').for(:confluence_url) }
+ it { is_expected.to allow_value('http://example.atlassian.net/wiki').for(:confluence_url) }
+ it { is_expected.to allow_value('https://example.atlassian.net/wiki/').for(:confluence_url) }
+ it { is_expected.to allow_value('http://example.atlassian.net/wiki/').for(:confluence_url) }
+ it { is_expected.to allow_value('https://example.atlassian.net/wiki/foo').for(:confluence_url) }
+
+ it { is_expected.to validate_presence_of(:confluence_url) }
+ end
+
+ context 'when service is inactive' do
+ let(:active) { false }
+
+ it { is_expected.not_to validate_presence_of(:confluence_url) }
+ it { is_expected.to allow_value('foo').for(:confluence_url) }
+ end
+ end
+
+ describe '#detailed_description' do
+ it 'can correctly return a link to the project wiki when active' do
+ project = create(:project)
+ subject.project = project
+ subject.active = true
+
+ expect(subject.detailed_description).to include(Gitlab::Routing.url_helpers.project_wikis_url(project))
+ end
+
+ context 'when the project wiki is not enabled' do
+ it 'returns nil when both active or inactive', :aggregate_failures do
+ project = create(:project, :wiki_disabled)
+ subject.project = project
+
+ [true, false].each do |active|
+ subject.active = active
+
+ expect(subject.detailed_description).to be_nil
+ end
+ end
+ end
+ end
+
+ describe 'Caching has_confluence on project_settings' do
+ let(:project) { create(:project) }
+
+ subject { project.project_setting.has_confluence? }
+
+ it 'sets the property to true when service is active' do
+ create(:confluence_service, project: project, active: true)
+
+ is_expected.to be(true)
+ end
+
+ it 'sets the property to false when service is not active' do
+ create(:confluence_service, project: project, active: false)
+
+ is_expected.to be(false)
+ end
+
+ it 'creates a project_setting record if one was not already created' do
+ expect { create(:confluence_service) }.to change { ProjectSetting.count }.by(1)
+ end
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 2fede93bfa5..e0da7e15240 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -63,6 +63,7 @@ RSpec.describe Project do
it { is_expected.to have_one(:bugzilla_service) }
it { is_expected.to have_one(:gitlab_issue_tracker_service) }
it { is_expected.to have_one(:external_wiki_service) }
+ it { is_expected.to have_one(:confluence_service) }
it { is_expected.to have_one(:project_feature) }
it { is_expected.to have_one(:project_repository) }
it { is_expected.to have_one(:container_expiration_policy) }
@@ -1041,6 +1042,32 @@ RSpec.describe Project do
end
end
+ describe '#has_confluence?' do
+ let_it_be(:project) { build_stubbed(:project) }
+
+ it 'returns false when project_setting.has_confluence property is false' do
+ project.project_setting.has_confluence = false
+
+ expect(project.has_confluence?).to be(false)
+ end
+
+ context 'when project_setting.has_confluence property is true' do
+ before do
+ project.project_setting.has_confluence = true
+ end
+
+ it 'returns true' do
+ expect(project.has_confluence?).to be(true)
+ end
+
+ it 'returns false when confluence integration feature flag is disabled' do
+ stub_feature_flags(ConfluenceService::FEATURE_FLAG => false)
+
+ expect(project.has_confluence?).to be(false)
+ end
+ end
+ end
+
describe '#external_wiki' do
let(:project) { create(:project) }
@@ -5385,6 +5412,20 @@ RSpec.describe Project do
expect(services.count).to eq(2)
expect(services.map(&:title)).to eq(['JetBrains TeamCity CI', 'Pushover'])
end
+
+ describe 'interaction with the confluence integration feature flag' do
+ it 'contains a ConfluenceService when feature flag is enabled' do
+ stub_feature_flags(ConfluenceService::FEATURE_FLAG => true)
+
+ expect(subject.find_or_initialize_services).to include(ConfluenceService)
+ end
+
+ it 'does not contain a ConfluenceService when the confluence integration feature flag is disabled' do
+ stub_feature_flags(ConfluenceService::FEATURE_FLAG => false)
+
+ expect(subject.find_or_initialize_services).not_to include(ConfluenceService)
+ end
+ end
end
describe '#find_or_initialize_service' do
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index a1445e3740a..53c57931d36 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -36,9 +36,9 @@ RSpec.describe API::Jobs do
end
let_it_be(:pipeline, reload: true) do
- create(:ci_empty_pipeline, project: project,
- sha: project.commit.id,
- ref: project.default_branch)
+ create(:ci_pipeline, project: project,
+ sha: project.commit.id,
+ ref: project.default_branch)
end
let!(:job) do
diff --git a/spec/services/branches/delete_service_spec.rb b/spec/services/branches/delete_service_spec.rb
index b57817e9f59..f1e7c9340b1 100644
--- a/spec/services/branches/delete_service_spec.rb
+++ b/spec/services/branches/delete_service_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Branches::DeleteService do
subject(:service) { described_class.new(project, user) }
shared_examples 'a deleted branch' do |branch_name|
+ before do
+ allow(Ci::RefDeleteUnlockArtifactsWorker).to receive(:perform_async)
+ end
+
it 'removes the branch' do
expect(branch_exists?(branch_name)).to be true
@@ -18,6 +22,12 @@ RSpec.describe Branches::DeleteService do
expect(result.status).to eq :success
expect(branch_exists?(branch_name)).to be false
end
+
+ it 'calls the RefDeleteUnlockArtifactsWorker' do
+ expect(Ci::RefDeleteUnlockArtifactsWorker).to receive(:perform_async).with(project.id, user.id, "refs/heads/#{branch_name}")
+
+ service.execute(branch_name)
+ end
end
describe '#execute' do
diff --git a/spec/services/ci/create_job_artifacts_service_spec.rb b/spec/services/ci/create_job_artifacts_service_spec.rb
index 56bbf0a743d..3f5cf079025 100644
--- a/spec/services/ci/create_job_artifacts_service_spec.rb
+++ b/spec/services/ci/create_job_artifacts_service_spec.rb
@@ -30,26 +30,6 @@ RSpec.describe Ci::CreateJobArtifactsService do
describe '#execute' do
subject { service.execute(artifacts_file, params, metadata_file: metadata_file) }
- context 'locking' do
- let(:old_job) { create(:ci_build, pipeline: create(:ci_pipeline, project: job.project, ref: job.ref)) }
- let!(:latest_artifact) { create(:ci_job_artifact, job: old_job, locked: true) }
- let!(:other_artifact) { create(:ci_job_artifact, locked: true) }
-
- it 'locks the new artifact' do
- subject
-
- expect(Ci::JobArtifact.last).to have_attributes(locked: true)
- end
-
- it 'unlocks all other artifacts for the same ref' do
- expect { subject }.to change { latest_artifact.reload.locked }.from(true).to(false)
- end
-
- it 'does not unlock artifacts for other refs' do
- expect { subject }.not_to change { other_artifact.reload.locked }.from(true)
- end
- end
-
context 'when artifacts file is uploaded' do
it 'saves artifact for the given type' do
expect { subject }.to change { Ci::JobArtifact.count }.by(1)
diff --git a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
index 1ec9e8df800..79443f16276 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
context 'when artifact is expired' do
context 'when artifact is not locked' do
before do
- artifact.update!(locked: false)
+ artifact.job.pipeline.unlocked!
end
it 'destroys job artifact' do
@@ -24,7 +24,7 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
context 'when artifact is locked' do
before do
- artifact.update!(locked: true)
+ artifact.job.pipeline.artifacts_locked!
end
it 'does not destroy job artifact' do
diff --git a/spec/services/ci/unlock_artifacts_service_spec.rb b/spec/services/ci/unlock_artifacts_service_spec.rb
new file mode 100644
index 00000000000..8d289a867ba
--- /dev/null
+++ b/spec/services/ci/unlock_artifacts_service_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::UnlockArtifactsService do
+ describe '#execute' do
+ subject(:execute) { described_class.new(pipeline.project, pipeline.user).execute(ci_ref, before_pipeline) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ [true, false].each do |tag|
+ context "when tag is #{tag}" do
+ let(:ref) { 'master' }
+ let(:ref_path) { tag ? "#{::Gitlab::Git::TAG_REF_PREFIX}#{ref}" : "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{ref}" }
+ let(:ci_ref) { create(:ci_ref, ref_path: ref_path) }
+
+ let!(:old_unlocked_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :unlocked) }
+ let!(:older_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) }
+ let!(:older_ambiguous_pipeline) { create(:ci_pipeline, ref: ref, tag: !tag, project: ci_ref.project, locked: :artifacts_locked) }
+ let!(:pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) }
+ let!(:child_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) }
+ let!(:newer_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) }
+ let!(:other_ref_pipeline) { create(:ci_pipeline, ref: 'other_ref', tag: tag, project: ci_ref.project, locked: :artifacts_locked) }
+
+ before do
+ create(:ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: pipeline),
+ source_project: ci_ref.project,
+ pipeline: child_pipeline,
+ project: ci_ref.project)
+ end
+
+ context 'when running on a ref before a pipeline' do
+ let(:before_pipeline) { pipeline }
+
+ it 'unlocks artifacts from older pipelines' do
+ expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+
+ it 'does not unlock artifacts for tag or branch with same name as ref' do
+ expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked')
+ end
+
+ it 'does not unlock artifacts from newer pipelines' do
+ expect { execute }.not_to change { newer_pipeline.reload.locked }.from('artifacts_locked')
+ end
+
+ it 'does not lock artifacts from old unlocked pipelines' do
+ expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked')
+ end
+
+ it 'does not unlock artifacts from the same pipeline' do
+ expect { execute }.not_to change { pipeline.reload.locked }.from('artifacts_locked')
+ end
+
+ it 'does not unlock artifacts for other refs' do
+ expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked')
+ end
+
+ it 'does not unlock artifacts for child pipeline' do
+ expect { execute }.not_to change { child_pipeline.reload.locked }.from('artifacts_locked')
+ end
+ end
+
+ context 'when running on just the ref' do
+ let(:before_pipeline) { nil }
+
+ it 'unlocks artifacts from older pipelines' do
+ expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+
+ it 'unlocks artifacts from newer pipelines' do
+ expect { execute }.to change { newer_pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+
+ it 'unlocks artifacts from the same pipeline' do
+ expect { execute }.to change { pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+
+ it 'does not unlock artifacts for tag or branch with same name as ref' do
+ expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked')
+ end
+
+ it 'does not lock artifacts from old unlocked pipelines' do
+ expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked')
+ end
+
+ it 'does not unlock artifacts for other refs' do
+ expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index 5499e9f21d6..6ccf2d03e4a 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -635,6 +635,37 @@ RSpec.describe Git::BranchPushService, services: true do
end
end
+ describe 'artifacts' do
+ context 'create branch' do
+ let(:oldrev) { blankrev }
+
+ it 'does nothing' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker).not_to receive(:perform_async)
+
+ execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
+ end
+ end
+
+ context 'update branch' do
+ it 'does nothing' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker).not_to receive(:perform_async)
+
+ execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
+ end
+ end
+
+ context 'delete branch' do
+ let(:newrev) { blankrev }
+
+ it 'unlocks artifacts' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker)
+ .to receive(:perform_async).with(project.id, user.id, "refs/heads/#{branch}")
+
+ execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
+ end
+ end
+ end
+
describe 'Hooks' do
context 'run on a branch' do
it 'delegates to Git::BranchHooksService' do
diff --git a/spec/services/git/tag_push_service_spec.rb b/spec/services/git/tag_push_service_spec.rb
index d0673a10faa..87dbf79a245 100644
--- a/spec/services/git/tag_push_service_spec.rb
+++ b/spec/services/git/tag_push_service_spec.rb
@@ -10,9 +10,11 @@ RSpec.describe Git::TagPushService do
let(:project) { create(:project, :repository) }
let(:service) { described_class.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }) }
- let(:oldrev) { Gitlab::Git::BLANK_SHA }
+ let(:blankrev) { Gitlab::Git::BLANK_SHA }
+ let(:oldrev) { blankrev }
let(:newrev) { "8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b" } # gitlab-test: git rev-parse refs/tags/v1.1.0
- let(:ref) { 'refs/tags/v1.1.0' }
+ let(:tag) { 'v1.1.0' }
+ let(:ref) { "refs/tags/#{tag}" }
describe "Push tags" do
subject do
@@ -58,4 +60,35 @@ RSpec.describe Git::TagPushService do
end
end
end
+
+ describe 'artifacts' do
+ context 'create tag' do
+ let(:oldrev) { blankrev }
+
+ it 'does nothing' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker).not_to receive(:perform_async)
+
+ service.execute
+ end
+ end
+
+ context 'update tag' do
+ it 'does nothing' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker).not_to receive(:perform_async)
+
+ service.execute
+ end
+ end
+
+ context 'delete tag' do
+ let(:newrev) { blankrev }
+
+ it 'unlocks artifacts' do
+ expect(::Ci::RefDeleteUnlockArtifactsWorker)
+ .to receive(:perform_async).with(project.id, user.id, "refs/tags/#{tag}")
+
+ service.execute
+ end
+ end
+ end
end
diff --git a/spec/services/tags/destroy_service_spec.rb b/spec/services/tags/destroy_service_spec.rb
index 3551ce38268..6160f337552 100644
--- a/spec/services/tags/destroy_service_spec.rb
+++ b/spec/services/tags/destroy_service_spec.rb
@@ -11,6 +11,10 @@ RSpec.describe Tags::DestroyService do
describe '#execute' do
subject { service.execute(tag_name) }
+ before do
+ allow(Ci::RefDeleteUnlockArtifactsWorker).to receive(:perform_async)
+ end
+
it 'removes the tag' do
expect(repository).to receive(:before_remove_tag)
expect(service).to receive(:success)
@@ -18,6 +22,12 @@ RSpec.describe Tags::DestroyService do
service.execute('v1.1.0')
end
+ it 'calls the RefDeleteUnlockArtifactsWorker' do
+ expect(Ci::RefDeleteUnlockArtifactsWorker).to receive(:perform_async).with(project.id, user.id, 'refs/tags/v1.1.0')
+
+ service.execute('v1.1.0')
+ end
+
context 'when there is an associated release on the tag' do
let(:tag) { repository.tags.first }
let(:tag_name) { tag.name }
diff --git a/spec/support/helpers/notification_helpers.rb b/spec/support/helpers/notification_helpers.rb
index b3e0e7d811b..887d68de4e1 100644
--- a/spec/support/helpers/notification_helpers.rb
+++ b/spec/support/helpers/notification_helpers.rb
@@ -38,26 +38,26 @@ module NotificationHelpers
end
def expect_delivery_jobs_count(count)
- expect(ActionMailer::DeliveryJob).to have_been_enqueued.exactly(count).times
+ expect(ActionMailer::MailDeliveryJob).to have_been_enqueued.exactly(count).times
end
def expect_no_delivery_jobs
- expect(ActionMailer::DeliveryJob).not_to have_been_enqueued
+ expect(ActionMailer::MailDeliveryJob).not_to have_been_enqueued
end
def expect_any_delivery_jobs
- expect(ActionMailer::DeliveryJob).to have_been_enqueued.at_least(:once)
+ expect(ActionMailer::MailDeliveryJob).to have_been_enqueued.at_least(:once)
end
def have_enqueued_email(*args, mailer: "Notify", mail: "", delivery: "deliver_now")
- have_enqueued_job(ActionMailer::DeliveryJob).with(mailer, mail, delivery, *args)
+ have_enqueued_job(ActionMailer::MailDeliveryJob).with(mailer, mail, delivery, args: args)
end
def expect_enqueud_email(*args, mailer: "Notify", mail: "", delivery: "deliver_now")
- expect(ActionMailer::DeliveryJob).to have_been_enqueued.with(mailer, mail, delivery, *args)
+ expect(ActionMailer::MailDeliveryJob).to have_been_enqueued.with(mailer, mail, delivery, args: args)
end
def expect_not_enqueud_email(*args, mailer: "Notify", mail: "")
- expect(ActionMailer::DeliveryJob).not_to have_been_enqueued.with(mailer, mail, *args, any_args)
+ expect(ActionMailer::MailDeliveryJob).not_to have_been_enqueued.with(mailer, mail, args: any_args)
end
end
diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb
index bf4eac8f324..899b43ade01 100644
--- a/spec/support/shared_contexts/services_shared_context.rb
+++ b/spec/support/shared_contexts/services_shared_context.rb
@@ -12,6 +12,8 @@ Service.available_services_names.each do |service|
service_attrs_list.inject({}) do |hash, k|
if k =~ /^(token*|.*_token|.*_key)/
hash.merge!(k => 'secrettoken')
+ elsif service == 'confluence' && k == :confluence_url
+ hash.merge!(k => 'https://example.atlassian.net/wiki')
elsif k =~ /^(.*_url|url|webhook)/
hash.merge!(k => "http://example.com")
elsif service_klass.method_defined?("#{k}?")
diff --git a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
index a9e7b15d9bc..e43ce936b90 100644
--- a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
@@ -67,77 +67,51 @@ RSpec.shared_examples 'cacheable diff collection' do
end
describe '#write_cache' do
+ before do
+ expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
+ end
+
it 'calls Gitlab::Diff::HighlightCache#write_if_empty' do
expect(highlight_cache).to receive(:write_if_empty).once
subject.write_cache
end
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(cache_diff_stats_merge_request: true)
- expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
- end
-
- it 'calls Gitlab::Diff::StatsCache#write_if_empty with diff stats' do
- diff_stats = Gitlab::Git::DiffStatsCollection.new([])
-
- expect(diffable.project.repository)
- .to receive(:diff_stats).and_return(diff_stats)
-
- expect(stats_cache).to receive(:write_if_empty).once.with(diff_stats)
-
- subject.write_cache
- end
- end
+ it 'calls Gitlab::Diff::StatsCache#write_if_empty with diff stats' do
+ diff_stats = Gitlab::Git::DiffStatsCollection.new([])
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(cache_diff_stats_merge_request: false)
- end
+ expect(diffable.project.repository)
+ .to receive(:diff_stats).and_return(diff_stats)
- it 'does not call Gitlab::Diff::StatsCache#write_if_empty' do
- expect(stats_cache).not_to receive(:write_if_empty)
+ expect(stats_cache).to receive(:write_if_empty).once.with(diff_stats)
- subject.write_cache
- end
+ subject.write_cache
end
end
describe '#clear_cache' do
+ before do
+ expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
+ end
+
it 'calls Gitlab::Diff::HighlightCache#clear' do
expect(highlight_cache).to receive(:clear).once
subject.clear_cache
end
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(cache_diff_stats_merge_request: true)
- expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
- end
-
- it 'calls Gitlab::Diff::StatsCache#clear' do
- expect(stats_cache).to receive(:clear).once
-
- subject.clear_cache
- end
- end
-
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(cache_diff_stats_merge_request: false)
- end
-
- it 'does not calls Gitlab::Diff::StatsCache#clear' do
- expect(stats_cache).not_to receive(:clear)
+ it 'calls Gitlab::Diff::StatsCache#clear' do
+ expect(stats_cache).to receive(:clear).once
- subject.clear_cache
- end
+ subject.clear_cache
end
end
describe '#diff_files' do
+ before do
+ expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
+ end
+
it 'calls Gitlab::Diff::HighlightCache#decorate' do
expect(highlight_cache).to receive(:decorate)
.with(instance_of(Gitlab::Diff::File))
@@ -146,40 +120,19 @@ RSpec.shared_examples 'cacheable diff collection' do
subject.diff_files
end
- context 'when the feature swtich is enabled' do
+ context 'when there are stats cached' do
before do
- stub_feature_flags(cache_diff_stats_merge_request: true)
- expect(Gitlab::Diff::StatsCache).to receive(:new).with(cachable_key: diffable.cache_key) { stats_cache }
- end
-
- context 'when there are stats cached' do
- before do
- allow(stats_cache).to receive(:read).and_return(Gitlab::Git::DiffStatsCollection.new([]))
- end
-
- it 'does not make a diff stats rpc call' do
- expect(diffable.project.repository).not_to receive(:diff_stats)
-
- subject.diff_files
- end
+ allow(stats_cache).to receive(:read).and_return(Gitlab::Git::DiffStatsCollection.new([]))
end
- context 'when there are no stats cached' do
- it 'makes a diff stats rpc call' do
- expect(diffable.project.repository)
- .to receive(:diff_stats)
- .with(diffable.diff_refs.base_sha, diffable.diff_refs.head_sha)
+ it 'does not make a diff stats rpc call' do
+ expect(diffable.project.repository).not_to receive(:diff_stats)
- subject.diff_files
- end
+ subject.diff_files
end
end
- context 'when the feature switch is disabled' do
- before do
- stub_feature_flags(cache_diff_stats_merge_request: false)
- end
-
+ context 'when there are no stats cached' do
it 'makes a diff stats rpc call' do
expect(diffable.project.repository)
.to receive(:diff_stats)
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index 5cf7baa1dd0..d748e1f8756 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'does not show the wiki tab' do
render
- expect(rendered).not_to have_link('Wiki', href: wiki_path(project.wiki))
+ expect(rendered).not_to have_link('Wiki')
end
end
end
@@ -109,6 +109,38 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
+ describe 'confluence tab' do
+ let!(:service) { create(:confluence_service, project: project, active: active) }
+
+ before do
+ render
+ end
+
+ context 'when the Confluence integration is active' do
+ let(:active) { true }
+
+ it 'shows the Confluence tab' do
+ expect(rendered).to have_link('Confluence', href: service.confluence_url)
+ end
+
+ it 'does not show the GitLab wiki tab' do
+ expect(rendered).not_to have_link('Wiki')
+ end
+ end
+
+ context 'when it is disabled' do
+ let(:active) { false }
+
+ it 'does not show the Confluence tab' do
+ expect(rendered).not_to have_link('Confluence')
+ end
+
+ it 'shows the GitLab wiki tab' do
+ expect(rendered).to have_link('Wiki', href: wiki_path(project.wiki))
+ end
+ end
+ end
+
describe 'ci/cd settings tab' do
before do
project.update!(archived: project_archived)
diff --git a/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb b/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb
new file mode 100644
index 00000000000..cb2cf58d50b
--- /dev/null
+++ b/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::PipelineSuccessUnlockArtifactsWorker do
+ describe '#perform' do
+ subject(:perform) { described_class.new.perform(pipeline_id) }
+
+ include_examples 'an idempotent worker' do
+ subject(:idempotent_perform) { perform_multiple(pipeline.id, exec_times: 2) }
+
+ let!(:older_pipeline) do
+ create(:ci_pipeline, :success, :with_job, locked: :artifacts_locked).tap do |pipeline|
+ create(:ci_job_artifact, job: pipeline.builds.first)
+ end
+ end
+
+ let!(:pipeline) do
+ create(:ci_pipeline, :success, :with_job, ref: older_pipeline.ref, tag: older_pipeline.tag, project: older_pipeline.project, locked: :unlocked).tap do |pipeline|
+ create(:ci_job_artifact, job: pipeline.builds.first)
+ end
+ end
+
+ it 'unlocks the artifacts from older pipelines' do
+ expect { idempotent_perform }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+ end
+
+ context 'when pipeline exists' do
+ let(:pipeline) { create(:ci_pipeline, :success, :with_job) }
+ let(:pipeline_id) { pipeline.id }
+
+ context 'when pipeline has artifacts' do
+ before do
+ create(:ci_job_artifact, job: pipeline.builds.first)
+ end
+
+ it 'calls the service' do
+ service = spy(Ci::UnlockArtifactsService)
+ expect(Ci::UnlockArtifactsService).to receive(:new).and_return(service)
+
+ perform
+
+ expect(service).to have_received(:execute)
+ end
+ end
+
+ context 'when pipeline does not have artifacts' do
+ it 'does not call service' do
+ expect(Ci::UnlockArtifactsService).not_to receive(:new)
+
+ perform
+ end
+ end
+ end
+
+ context 'when pipeline does not exist' do
+ let(:pipeline_id) { non_existing_record_id }
+
+ it 'does not call service' do
+ expect(Ci::UnlockArtifactsService).not_to receive(:new)
+
+ perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
new file mode 100644
index 00000000000..d9f2dd326dd
--- /dev/null
+++ b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do
+ describe '#perform' do
+ subject(:perform) { described_class.new.perform(project_id, user_id, ref) }
+
+ let(:ref) { 'refs/heads/master' }
+
+ let(:project) { create(:project) }
+
+ include_examples 'an idempotent worker' do
+ subject(:idempotent_perform) { perform_multiple([project_id, user_id, ref], exec_times: 2) }
+
+ let(:project_id) { project.id }
+ let(:user_id) { project.creator.id }
+
+ let(:pipeline) { create(:ci_pipeline, ref: 'master', project: project, locked: :artifacts_locked) }
+
+ it 'unlocks the artifacts from older pipelines' do
+ expect { idempotent_perform }.to change { pipeline.reload.locked }.from('artifacts_locked').to('unlocked')
+ end
+ end
+
+ context 'when project exists' do
+ let(:project_id) { project.id }
+
+ context 'when user exists' do
+ let(:user_id) { project.creator.id }
+
+ context 'when ci ref exists' do
+ before do
+ create(:ci_ref, ref_path: ref)
+ end
+
+ it 'calls the service' do
+ service = spy(Ci::UnlockArtifactsService)
+ expect(Ci::UnlockArtifactsService).to receive(:new).and_return(service)
+
+ perform
+
+ expect(service).to have_received(:execute)
+ end
+ end
+
+ context 'when ci ref does not exist' do
+ it 'does not call the service' do
+ expect(Ci::UnlockArtifactsService).not_to receive(:new)
+
+ perform
+ end
+ end
+ end
+
+ context 'when user does not exist' do
+ let(:user_id) { non_existing_record_id }
+
+ it 'does not call service' do
+ expect(Ci::UnlockArtifactsService).not_to receive(:new)
+
+ perform
+ end
+ end
+ end
+
+ context 'when project does not exist' do
+ let(:project_id) { non_existing_record_id }
+ let(:user_id) { project.creator.id }
+
+ it 'does not call service' do
+ expect(Ci::UnlockArtifactsService).not_to receive(:new)
+
+ perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index b88f0bce2ac..3bb9db07ff3 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'Every Sidekiq worker' do
file_worker_queues = Gitlab::SidekiqConfig.worker_queues.to_set
worker_queues = Gitlab::SidekiqConfig.workers.map(&:queue).to_set
- worker_queues << ActionMailer::DeliveryJob.new.queue_name
+ worker_queues << ActionMailer::MailDeliveryJob.new.queue_name
worker_queues << 'default'
missing_from_file = worker_queues - file_worker_queues