diff options
Diffstat (limited to 'app')
228 files changed, 3286 insertions, 1162 deletions
diff --git a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js new file mode 100644 index 00000000000..b4803be4d52 --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js @@ -0,0 +1,8 @@ +import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer'; + +export default () => { + new PayloadPreviewer( + document.querySelector('.js-usage-ping-payload-trigger'), + document.querySelector('.js-usage-ping-payload'), + ).init(); +}; diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 9a30ed93330..056b4ea4aa8 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,5 +1,6 @@ <script> import { initEditorLite } from '~/blob/utils'; +import { debounce } from 'lodash'; export default { props: { @@ -32,16 +33,14 @@ export default { }); }, methods: { - triggerFileChange() { + triggerFileChange: debounce(function debouncedFileChange() { this.$emit('input', this.editor.getValue()); - }, + }, 250), }, }; </script> <template> <div class="file-content code"> - <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{ - value - }}</pre> + <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue new file mode 100644 index 00000000000..f5c2cc57f3f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -0,0 +1,169 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + name: 'CiKeyField', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + model: { + prop: 'value', + event: 'input', + }, + props: { + tokenList: { + type: Array, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + data() { + return { + results: [], + arrowCounter: -1, + userDismissedResults: false, + suggestionsId: uniqueId('token-suggestions-'), + }; + }, + computed: { + showAutocomplete() { + return this.showSuggestions ? 'off' : 'on'; + }, + showSuggestions() { + return this.results.length > 0; + }, + }, + mounted() { + document.addEventListener('click', this.handleClickOutside); + }, + destroyed() { + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + closeSuggestions() { + this.results = []; + this.arrowCounter = -1; + }, + handleClickOutside(event) { + if (!this.$el.contains(event.target)) { + this.closeSuggestions(); + } + }, + onArrowDown() { + const newCount = this.arrowCounter + 1; + + if (newCount >= this.results.length) { + this.arrowCounter = 0; + return; + } + + this.arrowCounter = newCount; + }, + onArrowUp() { + const newCount = this.arrowCounter - 1; + + if (newCount < 0) { + this.arrowCounter = this.results.length - 1; + return; + } + + this.arrowCounter = newCount; + }, + onEnter() { + const currentToken = this.results[this.arrowCounter] || this.value; + this.selectToken(currentToken); + }, + onEsc() { + if (!this.showSuggestions) { + this.$emit('input', ''); + } + this.closeSuggestions(); + this.userDismissedResults = true; + }, + onEntry(value) { + this.$emit('input', value); + this.userDismissedResults = false; + + // short circuit so that we don't false match on empty string + if (value.length < 1) { + this.closeSuggestions(); + return; + } + + const filteredTokens = this.tokenList.filter(token => + token.toLowerCase().includes(value.toLowerCase()), + ); + + if (filteredTokens.length) { + this.openSuggestions(filteredTokens); + } else { + this.closeSuggestions(); + } + }, + openSuggestions(filteredResults) { + this.results = filteredResults; + }, + selectToken(value) { + this.$emit('input', value); + this.closeSuggestions(); + this.$emit('key-selected'); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> + <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <gl-form-input + id="ci-variable-key" + :value="value" + type="text" + role="searchbox" + class="form-control pl-2 js-env-input" + :autocomplete="showAutocomplete" + aria-autocomplete="list" + aria-controls="token-suggestions" + aria-haspopup="listbox" + :aria-expanded="showSuggestions" + data-qa-selector="ci_variable_key_field" + @input="onEntry" + @keydown.down="onArrowDown" + @keydown.up="onArrowUp" + @keydown.enter.prevent="onEnter" + @keydown.esc.stop="onEsc" + @keydown.tab="closeSuggestions" + /> + </gl-form-group> + + <div + v-show="showSuggestions && !userDismissedResults" + id="ci-variable-dropdown" + class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" + :class="{ 'd-block': showSuggestions }" + > + <div class="dropdown-content"> + <ul :id="suggestionsId"> + <li + v-for="(result, i) in results" + :key="i" + role="option" + :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :aria-selected="i === arrowCounter" + > + <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ + result + }}</gl-button> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..9022bf51514 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + /* Checks for exactly twenty characters that match key. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9]{20}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + /* Checks for exactly forty characters that match secret. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, +}; + +export const awsTokenList = Object.keys(awsTokens); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 316408adfb2..8f5acd4a0a0 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -1,8 +1,4 @@ <script> -import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlDeprecatedButton, GlModal, @@ -14,11 +10,19 @@ import { GlLink, GlIcon, } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +import CiKeyField from './ci_key_field.vue'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { CiEnvironmentsDropdown, + CiKeyField, GlDeprecatedButton, GlModal, GlFormSelect, @@ -29,6 +33,9 @@ export default { GlLink, GlIcon, }, + mixins: [glFeatureFlagsMixin()], + tokens: awsTokens, + tokenList: awsTokenList, computed: { ...mapState([ 'projectId', @@ -41,23 +48,24 @@ export default { 'selectedEnvironment', ]), canSubmit() { - if (this.variableData.masked && this.maskedState === false) { - return false; - } - return this.variableData.key !== '' && this.variableData.secret_value !== ''; + return ( + this.variableValidationState && + this.variableData.key !== '' && + this.variableData.secret_value !== '' + ); }, canMask() { const regex = RegExp(this.maskableRegex); return regex.test(this.variableData.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked && this.variableData.secret_value !== ''; + return !this.canMask && this.variableData.masked; }, maskedState() { if (this.displayMaskedError) { return false; } - return null; + return true; }, variableData() { return this.variableBeingEdited || this.variable; @@ -66,7 +74,41 @@ export default { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, maskedFeedback() { - return __('This variable can not be masked'); + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + // If the feature flag is off, do not validate. Remove when flag is removed. + if (!this.glFeatures.ciKeyAutocomplete) { + return true; + } + + const validator = this.$options.tokens?.[this.variableData.key]?.validation; + + if (validator) { + return validator(this.variableData.secret_value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + if ( + this.variableData.secret_value === '' || + (this.tokenValidationState && this.maskedState) + ) { + return true; + } + + return false; }, }, methods: { @@ -82,14 +124,13 @@ export default { 'resetSelectedEnvironment', 'setSelectedEnvironment', ]), - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); - } else { - this.addVariable(); - } + deleteVarAndClose() { + this.deleteVariable(this.variableBeingEdited); this.hideModal(); }, + hideModal() { + this.$refs.modal.hide(); + }, resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); @@ -98,11 +139,12 @@ export default { } this.resetSelectedEnvironment(); }, - hideModal() { - this.$refs.modal.hide(); - }, - deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(this.variableBeingEdited); + } else { + this.addVariable(); + } this.hideModal(); }, }, @@ -119,7 +161,13 @@ export default { @hidden="resetModalHandler" > <form> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <ci-key-field + v-if="glFeatures.ciKeyAutocomplete" + v-model="variableData.key" + :token-list="$options.tokenList" + /> + + <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" v-model="variableData.key" @@ -130,12 +178,14 @@ export default { <gl-form-group :label="__('Value')" label-for="ci-variable-value" - :state="maskedState" - :invalid-feedback="maskedFeedback" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" > <gl-form-textarea id="ci-variable-value" + ref="valueField" v-model="variableData.secret_value" + :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index d22138db102..5fe1e32e37e 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -14,3 +14,8 @@ export const types = { fileType: 'file', allEnvironmentsType: '*', }; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 6bc4be7b93a..6af9b10f12f 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -191,7 +191,8 @@ const applicationStateMachine = { * @param {*} event */ const transitionApplicationState = (application, event) => { - const newState = applicationStateMachine[application.status].on[event]; + const stateMachine = applicationStateMachine[application.status]; + const newState = stateMachine !== undefined ? stateMachine.on[event] : false; return newState ? { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 51879f280e0..41988f321e5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,6 +1,6 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import Cookies from 'js-cookie'; -import _ from 'underscore'; import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -43,7 +43,7 @@ export default class ContextualSidebar { $(document).trigger('content.resize'); }); - $(window).on('resize', () => _.debounce(this.render(), 100)); + $(window).on('resize', debounce(() => this.render(), 100)); } // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 229612f5e9d..ba585444ba5 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,5 +1,5 @@ /* eslint-disable no-new */ -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import DropLab from './droplab/drop_lab'; @@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = false; this.isGettingRef = false; this.mergeRequestCreated = false; - this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); this.refIsValid = true; this.refsPath = this.wrapperEl.dataset.refsPath; this.suggestedRef = this.refInput.value; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 6d2b11e39d3..f609ca5f22d 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -59,16 +59,10 @@ export default () => { service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), }; }, - defaultNumberOfSummaryItems: 3, computed: { currentStage() { return this.store.currentActiveStage(); }, - summaryTableColumnClass() { - return this.state.summary.length === this.$options.defaultNumberOfSummaryItems - ? 'col-sm-3' - : 'col-sm-4'; - }, }, created() { // Conditional check placed here to prevent this method from being called on the diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 9544fbe9fc5..514d26862a3 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -99,8 +99,12 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead && this.isLoggedIn && this.showCommentButton; + if (this.isLoggedIn && this.showCommentButton) { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead || gon.features?.mergeRefHeadComments; + } + + return false; }, isMatchLine() { return this.line.type === MATCH_LINE_TYPE; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index b07dfe5f33d..40e1aec42ed 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; +export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 14c51602f28..dd682060b4b 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -1,5 +1,6 @@ import { __, n__, sprintf } from '~/locale'; -import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants'; export const selectedTargetIndex = state => state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX; @@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected + + const diffHead = parseBoolean(getParameterByName('diff_head')); + const isBaseSelected = !state.startVersion && !diffHead; + const isHeadSelected = !state.startVersion && diffHead; + const baseVersion = { versionName: state.targetBranchName, version_index: DIFF_COMPARE_BASE_VERSION_INDEX, href: state.mergeRequestDiff.base_version_path, isBase: true, - selected: !state.startVersion, + selected: isBaseSelected, + }; + + const headVersion = { + versionName: state.targetBranchName, + version_index: DIFF_COMPARE_HEAD_VERSION_INDEX, + href: state.mergeRequestDiff.head_version_path, + isHead: true, + selected: isHeadSelected, }; // Appended properties here are to make the compare_dropdown_layout easier to reason about const formatVersion = v => { @@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { ...v, }; }; - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index cc9bfa2e174..104686993a8 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -182,15 +182,18 @@ export default { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; - const discussionLineCode = discussion.line_code; + const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])]; const fileHash = discussion.diff_file.file_hash; const lineCheck = line => - line.line_code === discussionLineCode && - isDiscussionApplicableToLine({ - discussion, - diffPosition: diffPositionByLineCode[line.line_code], - latestDiff, - }); + discussionLineCodes.some( + discussionLineCode => + line.line_code === discussionLineCode && + isDiscussionApplicableToLine({ + discussion, + diffPosition: diffPositionByLineCode[line.line_code], + latestDiff, + }), + ); const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 9c788e283b9..dd8dec49a37 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -424,6 +424,7 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { old_path: file.old_path, old_line: line.old_line, new_line: line.new_line, + line_range: null, line_code: line.line_code, position_type: 'text', }; @@ -439,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD const { line_code, ...diffPositionCopy } = diffPosition; if (discussion.original_position && discussion.position) { - const originalRefs = discussion.original_position; - const refs = discussion.position; + const discussionPositions = [ + discussion.original_position, + discussion.position, + ...(discussion.positions || []), + ]; - return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy); + return discussionPositions.some(position => isEqual(position, diffPositionCopy)); } // eslint-disable-next-line diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index be2eee828ff..4aad54bed55 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; /** @@ -29,7 +29,7 @@ export default class FilterableList { initSearch() { // Wrap to prevent passing event arguments to .filterResults; - this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500); + this.debounceFilter = debounce(this.onFilterInput.bind(this), 500); this.unbindEvents(); this.bindEvents(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 7ea7313f648..724f80f8866 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -14,7 +14,13 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; -import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes'; +import { + ENTER_KEY_CODE, + BACKSPACE_KEY_CODE, + DELETE_KEY_CODE, + UP_KEY_CODE, + DOWN_KEY_CODE, +} from '~/lib/utils/keycodes'; import { __ } from '~/locale'; export default class FilteredSearchManager { @@ -176,6 +182,8 @@ export default class FilteredSearchManager { this.checkForEnterWrapper = this.checkForEnter.bind(this); this.onClearSearchWrapper = this.onClearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.call(this); + this.checkForMetaBackspaceWrapper = this.checkForMetaBackspace.bind(this); + this.checkForAltOrCtrlBackspaceWrapper = this.checkForAltOrCtrlBackspace.bind(this); this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.editTokenWrapper = this.editToken.bind(this); @@ -192,6 +200,9 @@ export default class FilteredSearchManager { this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); + // e.metaKey only works with keydown, not keyup + this.filteredSearchInput.addEventListener('keydown', this.checkForMetaBackspaceWrapper); + this.filteredSearchInput.addEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); @@ -213,6 +224,8 @@ export default class FilteredSearchManager { this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForMetaBackspaceWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); @@ -235,7 +248,11 @@ export default class FilteredSearchManager { return e => { // 8 = Backspace Key // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { + // Handled by respective backspace-combination check functions + if (e.altKey || e.ctrlKey || e.metaKey) { + return; + } + if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken); const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue); @@ -258,15 +275,31 @@ export default class FilteredSearchManager { }; } + checkForAltOrCtrlBackspace(e) { + if ((e.altKey || e.ctrlKey) && e.keyCode === BACKSPACE_KEY_CODE) { + // Default to native OS behavior if input value present + if (this.filteredSearchInput.value === '') { + FilteredSearchVisualTokens.removeLastTokenPartial(); + } + } + } + + checkForMetaBackspace(e) { + const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey; + if (onlyMeta && e.keyCode === BACKSPACE_KEY_CODE) { + this.clearSearch(); + } + } + checkForEnter(e) { - if (e.keyCode === 38 || e.keyCode === 40) { + if (e.keyCode === UP_KEY_CODE || e.keyCode === DOWN_KEY_CODE) { const { selectionStart } = this.filteredSearchInput; e.preventDefault(); this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); } - if (e.keyCode === 13) { + if (e.keyCode === ENTER_KEY_CODE) { const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; const dropdownEl = dropdown.element; const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); @@ -375,7 +408,7 @@ export default class FilteredSearchManager { removeSelectedTokenKeydown(e) { // 8 = Backspace Key // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { + if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) { this.removeSelectedToken(); } } diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index f0f5b8395c9..c7acc21378b 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -32,7 +32,7 @@ export default { }, methods: { change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); + const filterGroupsParam = getParameterByName('filter'); const sortParam = getParameterByName('sort'); const archivedParam = getParameterByName('archived'); eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index 437239ce0be..b71c06e4217 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,12 +1,20 @@ <script> -import getJiraProjects from '../queries/getJiraProjects.query.graphql'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import { IMPORT_STATE, isInProgress } from '../utils'; import JiraImportForm from './jira_import_form.vue'; +import JiraImportProgress from './jira_import_progress.vue'; import JiraImportSetup from './jira_import_setup.vue'; export default { name: 'JiraImportApp', components: { + GlAlert, + GlLoadingIcon, JiraImportForm, + JiraImportProgress, JiraImportSetup, }, props: { @@ -14,6 +22,18 @@ export default { type: Boolean, required: true, }, + inProgressIllustration: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, projectPath: { type: String, required: true, @@ -23,26 +43,111 @@ export default { required: true, }, }, + data() { + return { + errorMessage: '', + showAlert: false, + }; + }, apollo: { - getJiraImports: { - query: getJiraProjects, + jiraImportDetails: { + query: getJiraImportDetailsQuery, variables() { return { fullPath: this.projectPath, }; }, - update: data => data.project.jiraImports, + update: ({ project }) => ({ + status: project.jiraImportStatus, + import: project.jiraImports.nodes[0], + }), skip() { return !this.isJiraConfigured; }, }, }, + computed: { + isImportInProgress() { + return isInProgress(this.jiraImportDetails?.status); + }, + jiraProjectsOptions() { + return this.jiraProjects.map(([text, value]) => ({ text, value })); + }, + }, + methods: { + dismissAlert() { + this.showAlert = false; + }, + initiateJiraImport(project) { + this.$apollo + .mutate({ + mutation: initiateJiraImportMutation, + variables: { + input: { + projectPath: this.projectPath, + jiraProjectKey: project, + }, + }, + update: (store, { data }) => { + if (data.jiraImportStart.errors.length) { + return; + } + + store.writeQuery({ + query: getJiraImportDetailsQuery, + variables: { + fullPath: this.projectPath, + }, + data: { + project: { + jiraImportStatus: IMPORT_STATE.SCHEDULED, + jiraImports: { + nodes: [data.jiraImportStart.jiraImport], + __typename: 'JiraImportConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Project', + }, + }, + }); + }, + }) + .then(({ data }) => { + if (data.jiraImportStart.errors.length) { + this.setAlertMessage(data.jiraImportStart.errors.join('. ')); + } + }) + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + }, + setAlertMessage(message) { + this.errorMessage = message; + this.showAlert = true; + }, + }, }; </script> <template> <div> + <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> + {{ errorMessage }} + </gl-alert> + <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" /> - <jira-import-form v-else /> + <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> + <jira-import-progress + v-else-if="isImportInProgress" + :illustration="inProgressIllustration" + :import-initiator="jiraImportDetails.import.scheduledBy.name" + :import-project="jiraImportDetails.import.jiraProjectKey" + :import-time="jiraImportDetails.import.scheduledAt" + :issues-path="issuesPath" + /> + <jira-import-form + v-else + :issues-path="issuesPath" + :jira-projects="jiraProjectsOptions" + @initiateJiraImport="initiateJiraImport" + /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 4de04efe1b0..0146f564260 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -1,17 +1,50 @@ <script> -import { GlAvatar, GlNewButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; +import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; export default { name: 'JiraImportForm', components: { GlAvatar, - GlNewButton, + GlButton, GlFormGroup, GlFormSelect, GlLabel, }, currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + props: { + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedOption: null, + selectState: null, + }; + }, + methods: { + initiateJiraImport(event) { + event.preventDefault(); + if (!this.selectedOption) { + this.showValidationError(); + } else { + this.hideValidationError(); + this.$emit('initiateJiraImport', this.selectedOption); + } + }, + hideValidationError() { + this.selectState = null; + }, + showValidationError() { + this.selectState = false; + }, + }, }; </script> @@ -19,14 +52,21 @@ export default { <div> <h3 class="page-title">{{ __('New Jira import') }}</h3> <hr /> - <form> + <form @submit="initiateJiraImport"> <gl-form-group class="row align-items-center" + :invalid-feedback="__('Please select a Jira project')" :label="__('Import from')" label-cols-sm="2" label-for="jira-project-select" > - <gl-form-select id="jira-project-select" class="mb-2" /> + <gl-form-select + id="jira-project-select" + v-model="selectedOption" + class="mb-2" + :options="jiraProjects" + :state="selectState" + /> </gl-form-group> <gl-form-group @@ -86,8 +126,10 @@ export default { </gl-form-group> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-new-button category="primary" variant="success">{{ __('Next') }}</gl-new-button> - <gl-new-button>{{ __('Cancel') }}</gl-new-button> + <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> + {{ __('Next') }} + </gl-button> + <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue new file mode 100644 index 00000000000..2d610224658 --- /dev/null +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -0,0 +1,66 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'JiraImportProgress', + components: { + GlEmptyState, + }, + props: { + illustration: { + type: String, + required: true, + }, + importInitiator: { + type: String, + required: true, + }, + importProject: { + type: String, + required: true, + }, + importTime: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + }, + computed: { + importInitiatorText() { + return sprintf(__('Import started by: %{importInitiator}'), { + importInitiator: this.importInitiator, + }); + }, + importProjectText() { + return sprintf(__('Jira project: %{importProject}'), { + importProject: this.importProject, + }); + }, + importTimeText() { + return sprintf(__('Time of import: %{importTime}'), { + importTime: formatDate(this.importTime), + }); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :svg-path="illustration" + :title="__('Import in progress')" + :primary-button-text="__('View issues')" + :primary-button-link="issuesPath" + > + <template #description> + <p class="mb-0">{{ importInitiatorText }}</p> + <p class="mb-0">{{ importTimeText }}</p> + <p class="mb-0">{{ importProjectText }}</p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 917930397f4..44773a773d5 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -1,6 +1,11 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; + export default { name: 'JiraImportSetup', + components: { + GlEmptyState, + }, props: { illustration: { type: String, @@ -11,15 +16,11 @@ export default { </script> <template> - <div class="empty-state"> - <div class="svg-content"> - <img :src="illustration" :alt="__('Set up Jira Integration illustration')" /> - </div> - <div class="text-content d-flex flex-column align-items-center"> - <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p> - <a class="btn btn-success" href="../services/jira/edit"> - {{ __('Set up Jira Integration') }} - </a> - </div> - </div> + <gl-empty-state + :svg-path="illustration" + title="" + :description="__('You will first need to set up Jira Integration to use this feature.')" + :primary-button-text="__('Set up Jira Integration')" + primary-button-link="../services/jira/edit" + /> </template> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 13b16b81c49..8bd70e4e277 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -24,7 +24,10 @@ export default function mountJiraImportApp() { render(createComponent) { return createComponent(App, { props: { + inProgressIllustration: el.dataset.inProgressIllustration, isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [], projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql deleted file mode 100644 index 13100eac221..00000000000 --- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query getJiraProjects($fullPath: ID!) { - project(fullPath: $fullPath) { - jiraImportStatus - jiraImports { - nodes { - jiraProjectKey - scheduledAt - scheduledBy { - username - } - } - } - } -} diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql new file mode 100644 index 00000000000..0eaaad580fc --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -0,0 +1,12 @@ +#import "./jira_import.fragment.graphql" + +query($fullPath: ID!) { + project(fullPath: $fullPath) { + jiraImportStatus + jiraImports(last: 1) { + nodes { + ...JiraImport + } + } + } +} diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql new file mode 100644 index 00000000000..8fda8287988 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -0,0 +1,11 @@ +#import "./jira_import.fragment.graphql" + +mutation($input: JiraImportStartInput!) { + jiraImportStart(input: $input) { + clientMutationId + jiraImport { + ...JiraImport + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql new file mode 100644 index 00000000000..fde2ebeff91 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -0,0 +1,7 @@ +fragment JiraImport on JiraImport { + jiraProjectKey + scheduledAt + scheduledBy { + name + } +} diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js new file mode 100644 index 00000000000..504cf19e44e --- /dev/null +++ b/app/assets/javascripts/jira_import/utils.js @@ -0,0 +1,10 @@ +export const IMPORT_STATE = { + FAILED: 'failed', + FINISHED: 'finished', + NONE: 'none', + SCHEDULED: 'scheduled', + STARTED: 'started', +}; + +export const isInProgress = state => + state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED; diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 9e8edd05b88..a464290ffb5 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { debounce, throttle } from 'lodash'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; @@ -82,7 +82,7 @@ export default class LazyLoader { } startIntersectionObserver = () => { - this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300); + this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300); this.intersectionObserver = new IntersectionObserver(this.onIntersection, { rootMargin: `${SCROLL_THRESHOLD}px 0px`, thresholds: 0.1, @@ -102,8 +102,8 @@ export default class LazyLoader { }; startLegacyObserver() { - this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); - this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); + this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300); + this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300); window.addEventListener('scroll', this.throttledScrollCheck); window.addEventListener('resize', this.debouncedElementsInView); } diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 2270d329c24..16bffc5c2cf 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -1,5 +1,6 @@ -export const UP_KEY_CODE = 38; -export const DOWN_KEY_CODE = 40; +export const BACKSPACE_KEY_CODE = 8; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; -export const BACKSPACE_KEY_CODE = 8; +export const UP_KEY_CODE = 38; +export const DOWN_KEY_CODE = 40; +export const DELETE_KEY_CODE = 46; diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index d3aea37e677..adf374db66c 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -1,3 +1,4 @@ +import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils'; import { s__ } from '~/locale'; import { @@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = { gibibytes: 'gibibytes', tebibytes: 'tebibytes', pebibytes: 'pebibytes', + + // Engineering Notation + engineering: 'engineering', }; /** * Returns a function that formats number to different units - * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number. + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. * * */ -export const getFormatter = (format = SUPPORTED_FORMATS.number) => { +export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { // Number if (format === SUPPORTED_FORMATS.number) { @@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => { return scaledBinaryFormatter('B', 5); } + if (format === SUPPORTED_FORMATS.engineering) { + /** + * Formats via engineering notation + * + * @function + * @param {Number} value - Value to format + * @param {Number} fractionDigits - precision decimals - Defaults to 2 + */ + return engineeringNotation; + } + // Fail so client library addresses issue throw TypeError(`${format} is not a valid number format`); }; diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index 450b83f4827..51770aa7a1c 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -1,3 +1,3 @@ -export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"'; +export const dateFormatMask = 'mmm dd HH:MM:ss.l'; export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 81b2e9f13a5..6c8f6372795 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => { if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } } }); diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js index 947750b3721..418107c4126 100644 --- a/app/assets/javascripts/monitoring/components/charts/annotations.js +++ b/app/assets/javascripts/monitoring/components/charts/annotations.js @@ -1,20 +1,20 @@ -import { graphTypes, symbolSizes, colorValues } from '../../constants'; +import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants'; /** * Annotations and deployments are decoration layers on * top of the actual chart data. We use a scatter plot to * display this information. Each chart has its coordinate - * system based on data and irresptive of the data, these + * system based on data and irrespective of the data, these * decorations have to be placed in specific locations. * For this reason, annotations have their own coordinate system, * * As of %12.9, only deployment icons, a type of annotations, need * to be displayed on the chart. * - * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418, - * annotations and deployments will co-exist in the same - * series as they logically belong together. Annotations will be - * passed as markLine objects. + * Annotations and deployments co-exist in the same series as + * they logically belong together. Annotations are passed as + * markLines and markPoints while deployments are passed as + * data points with custom icons. */ /** @@ -45,42 +45,49 @@ export const annotationsYAxis = { * Fetched list of annotations are parsed into a * format the eCharts accepts to draw markLines * - * If Annotation is a single line, the `starting_at` property - * has a value and the `ending_at` is null. Because annotations - * only supports lines the `ending_at` value does not exist yet. - * + * If Annotation is a single line, the `startingAt` property + * has a value and the `endingAt` is null. Because annotations + * only supports lines the `endingAt` value does not exist yet. * * @param {Object} annotation object * @returns {Object} markLine object */ -export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({ - xAxis: starting_at, - lineStyle: { - color, - }, -}); +export const parseAnnotations = annotations => + annotations.reduce( + (acc, annotation) => { + acc.lines.push({ + xAxis: annotation.startingAt, + lineStyle: { + color: colorValues.primaryColor, + }, + }); + + acc.points.push({ + name: 'annotations', + xAxis: annotation.startingAt, + yAxis: annotationsYAxisCoords.min, + tooltipData: { + title: annotation.startingAt, + content: annotation.description, + }, + }); + + return acc; + }, + { lines: [], points: [] }, + ); /** - * This method currently generates deployments and annotations - * but are not used in the chart. The method calling - * generateAnnotationsSeries will not pass annotations until - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is - * implemented. - * - * This method is extracted out of the charts so that - * annotation lines can be easily supported in - * the future. - * - * In order to make hover work, hidden annotation data points - * are created along with the markLines. These data points have - * the necessart metadata that is used to display in the tooltip. + * This method generates a decorative series that has + * deployments as data points with custom icons and + * annotations as markLines and markPoints * * @param {Array} deployments deployments data * @returns {Object} annotation series object */ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => { // deployment data points - const deploymentsData = deployments.map(deployment => { + const data = deployments.map(deployment => { return { name: 'deployments', value: [deployment.createdAt, annotationsYAxisCoords.pos], @@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } }; }); - // annotation data points - const annotationsData = annotations.map(annotation => { - return { - name: 'annotations', - value: [annotation.starting_at, annotationsYAxisCoords.pos], - // style options - symbol: 'none', - // metadata that are accessible in `formatTooltipText` method - tooltipData: { - description: annotation.description, - }, - }; - }); + const parsedAnnotations = parseAnnotations(annotations); - // annotation markLine option + // markLine option draws the annotations dotted line const markLine = { symbol: 'none', silent: true, - data: annotations.map(parseAnnotations), + data: parsedAnnotations.lines, + }; + + // markPoints are the arrows under the annotations lines + const markPoint = { + symbol: annotationsSymbolIcon, + symbolSize: '8', + symbolOffset: [0, ' 60%'], + data: parsedAnnotations.points, }; return { + name: 'annotations', type: graphTypes.annotationsData, yAxisIndex: 1, // annotationsYAxis index - data: [...deploymentsData, ...annotationsData], + data, markLine, + markPoint, }; }; diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index 5588d9ac060..e015ef32d8c 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e import { chartHeight } from '../../constants'; export default { - props: { - graphTitle: { - type: String, - required: true, - }, - }, data() { return { height: chartHeight, diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index d9f49bd81f5..09b03774580 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1]; * Max string length of formatted axis tick */ const maxDataAxisTickLength = 8; - // Defaults -const defaultFormat = SUPPORTED_FORMATS.number; +const defaultFormat = SUPPORTED_FORMATS.engineering; const defaultYAxisFormat = defaultFormat; const defaultYAxisPrecision = 2; @@ -26,8 +25,7 @@ const chartGridLeft = 75; * @param {Object} param - Dashboard .yml definition options */ const getDataAxisOptions = ({ format, precision, name }) => { - const formatter = getFormatter(format); - + const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui return { name, nameLocation: 'center', // same as gitlab-ui's default diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 9041b01088c..bf40e8f448e 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants'; +import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -20,7 +20,6 @@ const events = { }; export default { - tooltipTypes, components: { GlAreaChart, GlLineChart, @@ -262,6 +261,21 @@ export default { isTooltipOfType(tooltipType, defaultType) { return tooltipType === defaultType; }, + /** + * This method is triggered when hovered over a single markPoint. + * + * The annotations title timestamp should match the data tooltip + * title. + * + * @params {Object} params markPoint object + * @returns {Object} + */ + formatAnnotationsTooltipText(params) { + return { + title: dateFormat(params.data?.tooltipData?.title, dateFormats.default), + content: params.data?.tooltipData?.content, + }; + }, formatTooltipText(params) { this.tooltip.title = dateFormat(params.value, dateFormats.default); this.tooltip.content = []; @@ -270,15 +284,10 @@ export default { if (dataPoint.value) { const [, yVal] = dataPoint.value; this.tooltip.type = dataPoint.name; - if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) { + if (this.tooltip.type === 'deployments') { const { data = {} } = dataPoint; this.tooltip.sha = data?.tooltipData?.sha; this.tooltip.commitUrl = data?.tooltipData?.commitUrl; - } else if ( - this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations) - ) { - const { data } = dataPoint; - this.tooltip.content.push(data?.tooltipData?.description); } else { const { seriesName, color, dataIndex } = dataPoint; @@ -356,6 +365,7 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" + :format-annotations-tooltip-text="formatAnnotationsTooltipText" :thresholds="thresholds" :width="width" :height="height" @@ -364,7 +374,7 @@ export default { @created="onChartCreated" @updated="onChartUpdated" > - <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)"> + <template v-if="tooltip.type === 'deployments'"> <template slot="tooltipTitle"> {{ __('Deployed') }} </template> @@ -373,16 +383,6 @@ export default { <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> - <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)"> - <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <div slot="tooltipContent" class="d-flex align-items-center"> - {{ tooltip.content.join('\n') }} - </div> - </template> <template v-else> <template slot="tooltipTitle"> <div class="text-nowrap"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4586ce70ad6..4d60b02d0df 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,7 +8,6 @@ import { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, - GlFormGroup, GlModal, GlLoadingIcon, GlSearchBoxByType, @@ -19,6 +18,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -46,8 +46,8 @@ export default { GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlFormGroup, GlModal, + CustomMetricsFormFields, DateTimePicker, GraphGroup, @@ -206,9 +206,6 @@ export default { }; }, computed: { - canAddMetrics() { - return this.customMetricsAvailable && this.customMetricsPath.length; - }, ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', @@ -229,7 +226,11 @@ export default { return !this.showEmptyState && this.rearrangePanelsAvailable; }, addingMetricsAvailable() { - return IS_EE && this.canAddMetrics && !this.showEmptyState; + return ( + this.customMetricsAvailable && + !this.showEmptyState && + this.firstDashboard === this.selectedDashboard + ); }, hasHeaderButtons() { return ( @@ -378,177 +379,164 @@ export default { <div v-if="showHeader" ref="prometheusGraphsHeader" - class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light" + class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > - <div class="row"> - <gl-form-group - :label="__('Dashboard')" - label-size="sm" - label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-6 col-lg-2" - > - <dashboards-dropdown - id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" - @selectDashboard="selectDashboard($event)" - /> - </gl-form-group> + <div class="mb-2 pr-2 d-flex d-sm-block"> + <dashboards-dropdown + id="monitor-dashboards-dropdown" + data-qa-selector="dashboards_filter_dropdown" + class="flex-grow-1" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> + </div> - <gl-form-group - :label="s__('Metrics|Environment')" - label-size="sm" - label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-6 col-lg-2" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + class="flex-grow-1" + data-qa-selector="environments_dropdown" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" > - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - data-qa-selector="environments_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{ - __('Environment') - }}</gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item - > - </div> - <div - v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" - ref="monitorEnvironmentsDropdownMsg" - class="text-secondary no-matches-message" + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> + {{ __('Environment') }} + </gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item > - {{ __('No matching results') }} - </div> </div> - </gl-dropdown> - </gl-form-group> + <div + v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" + ref="monitorEnvironmentsDropdownMsg" + class="text-secondary no-matches-message" + > + {{ __('No matching results') }} + </div> + </div> + </gl-dropdown> + </div> - <gl-form-group - :label="s__('Metrics|Show last')" - label-size="sm" - label-for="monitor-time-window-dropdown" - class="col-sm-auto col-md-auto col-lg-auto" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <date-time-picker + ref="dateTimePicker" + class="flex-grow-1 show-last-dropdown" data-qa-selector="show_last_dropdown" + :value="selectedTimeRange" + :options="timeRanges" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="refreshDashboardBtn" + v-gl-tooltip + class="flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + @click="refreshDashboard" > - <date-time-picker - ref="dateTimePicker" - :value="selectedTimeRange" - :options="timeRanges" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </gl-form-group> + <icon name="retry" /> + </gl-deprecated-button> + </div> + + <div class="flex-grow-1"></div> - <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button"> + <div class="d-sm-flex"> + <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip + :pressed="isRearrangingPanels" variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" + class="flex-grow-1 js-rearrange-button" + @click="toggleRearrangingPanels" > - <icon name="retry" /> + {{ __('Arrange charts') }} </gl-deprecated-button> - </gl-form-group> - - <gl-form-group - v-if="hasHeaderButtons" - label-for="prometheus-graphs-dropdown-buttons" - class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" - > - <div id="prometheus-graphs-dropdown-buttons"> - <gl-deprecated-button - v-if="showRearrangePanelsBtn" - :pressed="isRearrangingPanels" - variant="default" - class="mr-2 mt-1 js-rearrange-button" - @click="toggleRearrangingPanels" - >{{ __('Arrange charts') }}</gl-deprecated-button - > - <gl-deprecated-button - v-if="addingMetricsAvailable" - ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" - variant="outline-success" - data-qa-selector="add_metric_button" - class="mr-2 mt-1" - >{{ $options.addMetric.title }}</gl-deprecated-button - > - <gl-modal - v-if="addingMetricsAvailable" - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal">{{ - __('Cancel') - }}</gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - >{{ __('Save changes') }}</gl-deprecated-button - > - </div> - </gl-modal> + </div> + <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="addMetricBtn" + v-gl-modal="$options.addMetric.modalId" + variant="outline-success" + data-qa-selector="add_metric_button" + class="flex-grow-1" + > + {{ $options.addMetric.title }} + </gl-deprecated-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </div> - <gl-deprecated-button - v-if="selectedDashboard.can_edit" - class="mt-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - >{{ __('Edit dashboard') }}</gl-deprecated-button - > + <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + data-qa-selector="edit_dashboard_button" + > + {{ __('Edit dashboard') }} + </gl-deprecated-button> + </div> - <gl-deprecated-button - v-if="externalDashboardUrl.length" - class="mt-1 js-external-dashboard-link" - variant="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} - <icon name="external-link" /> - </gl-deprecated-button> - </div> - </gl-form-group> + <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} <icon name="external-link" /> + </gl-deprecated-button> + </div> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 676fc0cca64..2beae0d9540 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -4,6 +4,7 @@ import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; import { GlResizeObserverDirective, + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownItem, @@ -13,7 +14,6 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -37,7 +37,7 @@ export default { MonitorHeatmapChart, MonitorStackedColumnChart, MonitorEmptyChart, - Icon, + GlIcon, GlLoadingIcon, GlTooltip, GlDropdown, @@ -227,7 +227,7 @@ export default { </div> <div v-if="isContextualMenuShown" - class="js-graph-widgets" + ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > <div class="d-flex align-items-center"> @@ -240,7 +240,7 @@ export default { :title="__('More actions')" > <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> + <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item v-if="editCustomMetricLink" @@ -319,6 +319,6 @@ export default { :group-id="groupId" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" /> + <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 8d821c27099..0b393f19789 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -120,10 +120,26 @@ export const NOT_IN_DB_PREFIX = 'NO_DB'; export const ENVIRONMENT_AVAILABLE_STATE = 'available'; /** - * Time series charts have different types of - * tooltip based on the hovered data point. + * As of %12.10, the svg icon library does not have an annotation + * arrow icon yet. In order to deliver annotations feature, the icon + * is hard coded until the icon is added. The below issue is + * to track the icon. + * + * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118 + * + * Once the icon is merged this can be removed. + * https://gitlab.com/gitlab-org/gitlab/-/issues/214540 */ -export const tooltipTypes = { - deployments: 'deployments', - annotations: 'annotations', -}; +export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; + +/** + * As of %12.10, dashboard path is required to create annotation. + * The FE gets the dashboard name from the URL params. It is not + * ideal to store the path this way but there is no other way to + * get this path unless annotations fetch is delayed. This could + * potentially be removed and have the backend send this to the FE. + * + * This technical debt is being tracked here + * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 + */ +export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 2fd698eadf9..27b49860b8a 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -1,12 +1,25 @@ -query getAnnotations($projectPath: ID!) { - environment(name: $environmentName) { - metricDashboard(id: $dashboardId) { - annotations: nodes { +query getAnnotations( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! + $startingFrom: Time! +) { + project(fullPath: $projectPath) { + environments(name: $environmentName) { + nodes { id - description - starting_at - ending_at - panelId + name + metricsDashboard(path: $dashboardPath) { + annotations(from: $startingFrom) { + nodes { + id + description + startingAt + endingAt + panelId + } + } + } } } } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 5b2bd1f1493..f04f775761c 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,7 +3,12 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; +import { + gqClient, + parseEnvironmentsResponse, + parseAnnotationsResponse, + removeLeadingSlash, +} from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; @@ -15,7 +20,11 @@ import { } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; -import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; +import { + PROMETHEUS_TIMEOUT, + ENVIRONMENT_AVAILABLE_STATE, + DEFAULT_DASHBOARD_PATH, +} from '../constants'; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -90,7 +99,7 @@ export const fetchData = ({ dispatch }) => { * ready after the BE piece is implemented. * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 */ - if (isFeatureFlagEnabled('metrics_dashboard_annotations')) { + if (isFeatureFlagEnabled('metricsDashboardAnnotations')) { dispatch('fetchAnnotations'); } }; @@ -283,18 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { }; export const fetchAnnotations = ({ state, dispatch }) => { - dispatch('requestAnnotations'); - + const { start } = convertToFixedRange(state.timeRange); + const dashboardPath = + state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard; return gqClient .mutate({ mutation: getAnnotations, variables: { projectPath: removeLeadingSlash(state.projectPath), - dashboardId: state.currentDashboard, environmentName: state.currentEnvironmentName, + dashboardPath, + startingFrom: start, }, }) - .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations) + .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes) + .then(parseAnnotationsResponse) .then(annotations => { if (!annotations) { createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); @@ -309,9 +321,6 @@ export const fetchAnnotations = ({ state, dispatch }) => { }); }; -// While this commit does not update the state it will -// eventually be useful to show a loading state -export const requestAnnotations = ({ commit }) => commit(types.REQUEST_ANNOTATIONS); export const receiveAnnotationsSuccess = ({ commit }, data) => commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 2f9955da1b1..27a9a67edaa 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -4,7 +4,6 @@ export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCC export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; // Annotations -export const REQUEST_ANNOTATIONS = 'REQUEST_ANNOTATIONS'; export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a212e9be703..9f06d18c46f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => }); /** + * Annotation API returns time in UTC. This method + * converts time to local time. + * + * startingAt always exists but endingAt does not. + * If endingAt does not exist, a threshold line is + * drawn. + * + * If endingAt exists, a threshold range is drawn. + * But this is not supported as of %12.10 + * + * @param {Array} response annotations response + * @returns {Array} parsed responses + */ +export const parseAnnotationsResponse = response => { + if (!response) { + return []; + } + return response.map(annotation => ({ + ...annotation, + startingAt: new Date(annotation.startingAt), + endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, + })); +}; + +/** * Maps metrics to its view model * * This function difers from other in that is maps all @@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name }); /** * Maps Y-axis view model * - * Defaults to a 2 digit precision and `number` format. It only allows + * Defaults to a 2 digit precision and `engineering` format. It only allows * formats in the SUPPORTED_FORMATS array. * * @param {Object} axis */ -const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => { +const mapYAxisToViewModel = ({ + name = '', + format = SUPPORTED_FORMATS.engineering, + precision = 2, +}) => { return { name, - format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number, + format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, precision, }; }; diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index df62e379017..5181b5f26ee 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,17 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; import Flash from '../../flash'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; export default { components: { - Icon, - }, - directives: { - tooltip, + AwardsList, }, props: { awards: { @@ -37,130 +32,20 @@ export default { }, computed: { ...mapGetters(['getUserData']), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, + addButtonClass() { + return this.isAuthoredByMe ? 'js-user-authored' : ''; + }, }, methods: { ...mapActions(['toggleAwardRequest']), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(), - }; - }, - canInteractWithEmoji() { - return this.getUserData.id; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the beginning of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift(__('You')); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = sprintf( - __(`%{listToShow}, and %{awardsListLength} more.`), - { - listToShow: namesToShow.join(', '), - awardsListLength: remainingAwardList.length, - }, - false, - ); - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text - } else { - // We have only 2 users so join them with and. - title = namesToShow.join(__(' and ')); - } - - return title; - }, handleAward(awardName) { - if (!this.canAwardEmoji) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; - } - const data = { endpoint: this.toggleAwardPath, noteId: this.noteId, - awardName: parsedName, + awardName, }; this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); @@ -171,46 +56,12 @@ export default { <template> <div class="note-awards"> - <div class="awards js-awards-block"> - <button - v-for="(awardList, awardName, index) in groupedAwards" - :key="index" - v-tooltip - :class="getAwardClassBindings(awardList)" - :title="awardTitle(awardList)" - data-boundary="viewport" - class="btn award-control" - type="button" - @click="handleAward(awardName)" - > - <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter">{{ awardList.length }}</span> - </button> - <div v-if="canAwardEmoji" class="award-menu-holder"> - <button - v-tooltip - :class="{ 'js-user-authored': isAuthoredByMe }" - class="award-control btn js-add-award" - title="Add reaction" - :aria-label="__('Add reaction')" - data-boundary="viewport" - type="button" - > - <span class="award-control-icon award-control-icon-neutral"> - <icon name="slight-smile" /> - </span> - <span class="award-control-icon award-control-icon-positive"> - <icon name="smiley" /> - </span> - <span class="award-control-icon award-control-icon-super-positive"> - <icon name="smiley" /> - </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" - ></i> - </button> - </div> - </div> + <awards-list + :awards="awards" + :can-award-emoji="canAwardEmoji" + :current-user-id="getUserData.id" + :add-button-class="addButtonClass" + @award="handleAward($event)" + /> </div> </template> diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js index c40503603be..bbaaeb55c65 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js @@ -1,8 +1,3 @@ -import UsagePingPayload from './../usage_ping_payload'; +import setup from 'ee_else_ce/admin/application_settings/setup_metrics_and_profiling'; -document.addEventListener('DOMContentLoaded', () => { - new UsagePingPayload( - document.querySelector('.js-usage-ping-payload-trigger'), - document.querySelector('.js-usage-ping-payload'), - ).init(); -}); +document.addEventListener('DOMContentLoaded', setup); diff --git a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 95f4ba28b42..413045d960e 100644 --- a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -2,7 +2,7 @@ import axios from '../../../lib/utils/axios_utils'; import { __ } from '../../../locale'; import flash from '../../../flash'; -export default class UsagePingPayload { +export default class PayloadPreviewer { constructor(trigger, container) { this.trigger = trigger; this.container = container; @@ -38,7 +38,7 @@ export default class UsagePingPayload { }) .catch(() => { this.spinner.classList.remove('d-inline-flex'); - flash(__('Error fetching usage ping data.')); + flash(__('Error fetching payload data.')); }); } diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index a3743ded601..6efddec1172 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -24,7 +25,7 @@ export default { GlSprintf, GlLink, }, - mixins: [settingsMixin], + mixins: [settingsMixin, glFeatureFlagsMixin()], props: { currentSettings: { @@ -116,6 +117,8 @@ export default { const defaults = { visibilityOptions, visibilityLevel: visibilityOptions.PUBLIC, + // TODO: Change all of these to use the visibilityOptions constants + // https://gitlab.com/gitlab-org/gitlab/-/issues/214667 issuesAccessLevel: 20, repositoryAccessLevel: 20, forkingAccessLevel: 20, @@ -124,11 +127,14 @@ export default { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 20, + metricsAccessLevel: visibilityOptions.PRIVATE, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, emailsDisabled: false, + featureAccessLevelEveryone, + featureAccessLevelMembers, }; return { ...defaults, ...this.currentSettings }; @@ -189,6 +195,10 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', ); }, + + metricsDashboardVisibilitySwitchingAvailable() { + return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable; + }, }, watch: { @@ -462,6 +472,38 @@ export default { name="project[project_feature_attributes][pages_access_level]" /> </project-setting-row> + <project-setting-row + v-if="metricsDashboardVisibilitySwitchingAvailable" + ref="metrics-visibility-settings" + :label="__('Metrics Dashboard')" + :help-text=" + s__( + 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics', + ) + " + > + <div class="project-feature-controls"> + <div class="select-wrapper"> + <select + v-model="metricsAccessLevel" + name="project[project_feature_attributes][metrics_dashboard_access_level]" + class="form-control select-control" + > + <option + :value="visibilityOptions.PRIVATE" + :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + >{{ featureAccessLevelMembers[1] }}</option + > + <option + :value="visibilityOptions.PUBLIC" + :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + >{{ featureAccessLevelEveryone[1] }}</option + > + </select> + <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + </div> + </div> + </project-setting-row> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index e5898c3b047..2d321ead33e 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -53,6 +53,10 @@ export default { text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, + sse_middleman: { + text: s__('ProjectTemplates|Static Site Editor/Middleman'), + icon: '.template-option .icon-sse_middleman', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue index 6acf366e531..88a0710574f 100644 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue @@ -53,7 +53,6 @@ export default { :primary-button-text="alertConfiguration.primaryButton" :primary-button-link="config.settingsPath" :title="alertConfiguration.title" - class="my-2" > <gl-sprintf :message="alertConfiguration.message"> <template #days> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 586231d19c7..d4b9d25b212 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -1,16 +1,44 @@ import { s__ } from '~/locale'; +// List page + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, +); + +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the packages list.', + 'ContainerRegistry|Something went wrong while fetching the repository list.', ); export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while fetching the tags list.', ); - export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while deleting the image.', + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', ); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully'); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); + +// Image details page + export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while deleting the tag.', ); @@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); +// Expiration policies + export const EXPIRATION_POLICY_ALERT_TITLE = s__( 'ContainerRegistry|Retention policy has been Enabled', ); @@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', ); +// Quick Start + export const QUICK_START = s__('ContainerRegistry|Quick Start'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); @@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); + +// Image state + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 7204cbd90eb..8923c305b2d 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -9,16 +9,28 @@ import { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CONTAINER_REGISTRY_TITLE, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + LIST_INTRO_TEXT, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + REMOVE_REPOSITORY_MODAL_TEXT, + ROW_SCHEDULED_FOR_DELETION, +} from '../constants'; export default { name: 'RegistryListApp', @@ -35,6 +47,7 @@ export default { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, }, directives: { @@ -47,25 +60,20 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: s__('ContainerRegistry|Container Registry'), - connectionErrorTitle: s__('ContainerRegistry|Docker connection error'), - connectionErrorMessage: s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, - ), - introText: s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, - ), - deleteButtonDisabled: s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', - ), - removeRepositoryLabel: s__('ContainerRegistry|Remove repository'), - removeRepositoryModalText: s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', - ), + containerRegistryTitle: CONTAINER_REGISTRY_TITLE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, + introText: LIST_INTRO_TEXT, + deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, + removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, + removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, + rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, + asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, data() { return { itemToDelete: {}, + deleteAlertType: null, }; }, computed: { @@ -86,43 +94,61 @@ export default { showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, + showDeleteAlert() { + return this.deleteAlertType && this.itemToDelete?.path; + }, + deleteImageAlertMessage() { + return this.deleteAlertType === 'success' + ? DELETE_IMAGE_SUCCESS_MESSAGE + : DELETE_IMAGE_ERROR_MESSAGE; + }, }, methods: { ...mapActions(['requestImagesList', 'requestDeleteImage']), deleteImage(item) { - // This event is already tracked in the system and so the name must be kept to aggregate the data this.track('click_button'); this.itemToDelete = item; this.$refs.deleteModal.show(); }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete.destroy_path) - .then(() => - this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, { - type: 'error', - }), - ) - .finally(() => { - this.itemToDelete = {}; + return this.requestDeleteImage(this.itemToDelete) + .then(() => { + this.deleteAlertType = 'success'; + }) + .catch(() => { + this.deleteAlertType = 'danger'; }); }, encodeListItem(item) { const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); return window.btoa(params); }, + dismissDeleteAlert() { + this.deleteAlertType = null; + this.itemToDelete = {}; + }, }, }; </script> <template> <div class="w-100 slide-enter-from-element"> - <project-policy-alert v-if="!config.isGroupPage" /> + <gl-alert + v-if="showDeleteAlert" + :variant="deleteAlertType" + class="mt-2" + dismissible + @dismiss="dismissDeleteAlert" + > + <gl-sprintf :message="deleteImageAlertMessage"> + <template #title> + {{ itemToDelete.path }} + </template> + </gl-sprintf> + </gl-alert> + + <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> <gl-empty-state v-if="config.characterError" @@ -178,41 +204,57 @@ export default { v-for="(listItem, index) in images" :key="index" ref="rowItem" - :class="{ 'border-top': index === 0 }" - class="d-flex justify-content-between align-items-center py-2 border-bottom" + v-gl-tooltip="{ + placement: 'left', + disabled: !listItem.deleting, + title: $options.i18n.rowScheduledForDeletion, + }" > - <div> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.deleteButtonDisabled" + class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" + :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" + <div class="d-felx align-items-center"> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :disabled="listItem.deleting" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + <gl-icon + v-if="listItem.failedDelete" + v-gl-tooltip + :title="$options.i18n.asyncDeleteErrorMessage" + name="warning" + class="text-warning align-middle" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title="$options.i18n.deleteButtonDisabled" > - <gl-icon name="remove" /> - </gl-deprecated-button> + <gl-deprecated-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path || listItem.deleting" + :title="$options.i18n.removeRepositoryLabel" + :aria-label="$options.i18n.removeRepositoryLabel" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-deprecated-button> + </div> </div> </div> <gl-pagination diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 2abd72cb9a8..b4f66dbbcd6 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) }); }; -export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { +export const requestDeleteImage = ({ commit }, image) => { commit(types.SET_MAIN_LOADING, true); - return axios - .delete(destroyPath) + .delete(image.destroy_path) .then(() => { - dispatch('setShowGarbageCollectionTip', true); - dispatch('requestImagesList', { pagination: state.pagination }); + commit(types.UPDATE_IMAGE, { ...image, deleting: true }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js index 86eaa0dd2f1..f32cdf90783 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -1,6 +1,7 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const UPDATE_IMAGE = 'UPDATE_IMAGE'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index fda788051c0..b25a0221dc1 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; export default { [types.SET_INITIAL_STATE](state, config) { @@ -12,7 +13,17 @@ export default { }, [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images; + state.images = images.map(i => ({ + ...i, + status: undefined, + deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, + failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, + })); + }, + + [types.UPDATE_IMAGE](state, image) { + const index = state.images.findIndex(i => i.id === image.id); + state.images.splice(index, 1, { ...image }); }, [types.SET_TAGS_LIST_SUCCESS](state, tags) { diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue index df356c18417..8d68ff02116 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlNewButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { escape as esc } from 'lodash'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -15,7 +15,7 @@ export default { components: { GlFormInput, GlFormGroup, - GlNewButton, + GlButton, MarkdownField, AssetLinksForm, }, @@ -167,7 +167,7 @@ export default { <asset-links-form v-if="showAssetLinksForm" /> <div class="d-flex pt-3"> - <gl-new-button + <gl-button class="mr-auto js-no-auto-disable" category="primary" variant="success" @@ -176,10 +176,10 @@ export default { :disabled="isSaveChangesDisabled" > {{ __('Save changes') }} - </gl-new-button> - <gl-new-button :href="cancelPath" class="js-cancel-button"> + </gl-button> + <gl-button :href="cancelPath" class="js-cancel-button"> {{ __('Cancel') }} - </gl-new-button> + </gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 6ca700c2b30..4bdc88f01dd 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -4,7 +4,7 @@ import { GlSprintf, GlLink, GlFormGroup, - GlNewButton, + GlButton, GlIcon, GlTooltipDirective, GlFormInput, @@ -12,7 +12,7 @@ import { export default { name: 'AssetLinksForm', - components: { GlSprintf, GlLink, GlFormGroup, GlNewButton, GlIcon, GlFormInput }, + components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput }, directives: { GlTooltip: GlTooltipDirective }, computed: { ...mapState('detail', ['release', 'releaseAssetsDocsPath']), @@ -170,7 +170,7 @@ export default { </gl-form-group> <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto"> - <gl-new-button + <gl-button v-gl-tooltip class="remove-button w-100" :aria-label="__('Remove asset link')" @@ -179,16 +179,16 @@ export default { > <gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" /> <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span> - </gl-new-button> + </gl-button> </div> </div> - <gl-new-button + <gl-button ref="addAnotherLinkButton" variant="link" class="align-self-end mb-5 mb-sm-0" @click="onAddAnotherClicked" > {{ __('Add another link') }} - </gl-new-button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 6c58f48dc74..fdd6b4eb87a 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escape(this.ref), path)}`, + to: `/-/tree/${joinPaths(encodeURIComponent(this.ref), path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escape(this.ref)}/`, + to: `/-/tree/${encodeURIComponent(this.ref)}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index f9fcbc356e8..32bdda2e0a8 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -28,7 +28,7 @@ export default { return splitArray.map(p => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` }; + return { path: `/-/tree/${encodeURIComponent(this.commitRef)}/${this.parentPath}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 00ccc49d770..d9ef6eec6f1 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -99,7 +99,7 @@ export default { computed: { routerLinkTo() { return this.isFolder - ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` } + ? { path: `/-/tree/${encodeURIComponent(this.ref)}/${escapeFileUrl(this.path)}` } : null; }, isFolder() { diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 0c68b5a599b..6640b636597 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -48,7 +48,7 @@ const defaultClient = createDefaultClient( case 'TreeEntry': case 'Submodule': case 'Blob': - return `${escape(obj.flatPath)}-${obj.id}`; + return `${encodeURIComponent(obj.flatPath)}-${obj.id}`; default: // If the type doesn't match any of the above we fallback // to using the default Apollo ID diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 637060f6ed9..05783fc3b5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -100,7 +100,9 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`, + path: `${historyLink}/${ + this.$route.params.path ? encodeURIComponent(this.$route.params.path) : '' + }`, text: __('History'), }, }); diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index b2636f910fe..d74447dd566 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,7 +12,7 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `(/-)?/tree/(${encodeURIComponent(baseRef).replace(/%2F/g, '/')}|${baseRef})/:path*`, + path: `(/-)?/tree/(${encodeURIComponent(baseRef)}|${baseRef})/:path*`, name: 'treePath', component: TreePage, props: route => ({ diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index a098d17a226..b0d373b1a4b 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initSnippet from '~/snippet/snippet_bundle'; import ZenMode from '~/zen_mode'; import GLForm from '~/gl_form'; +import { SnippetEditInit } from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { const form = document.querySelector('.snippet-form'); @@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => { const projectSnippetOptions = {}; const options = - form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions; + form.dataset.snippetType === 'project' || form.dataset.projectPath + ? projectSnippetOptions + : personalSnippetOptions; - initSnippet(); + if (gon?.features?.snippetsEditVue) { + SnippetEditInit(); + } else { + initSnippet(); + new GLForm($(form), options); // eslint-disable-line no-new + } new ZenMode(); // eslint-disable-line no-new - new GLForm($(form), options); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue new file mode 100644 index 00000000000..2185b1d67e4 --- /dev/null +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -0,0 +1,211 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; + +import Flash from '~/flash'; +import { __, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import TitleField from '~/vue_shared/components/form/title.vue'; +import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; + +import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; +import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PRIVATE } from '../constants'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; +import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; +import SnippetDescriptionEdit from './snippet_description_edit.vue'; + +export default { + components: { + SnippetDescriptionEdit, + SnippetVisibilityEdit, + SnippetBlobEdit, + TitleField, + FormFooterActions, + GlButton, + GlLoadingIcon, + }, + mixins: [getSnippetMixin], + props: { + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + visibilityHelpLink: { + type: String, + default: '', + required: false, + }, + projectPath: { + type: String, + default: '', + required: false, + }, + }, + data() { + return { + blob: {}, + fileName: '', + content: '', + isContentLoading: true, + isUpdating: false, + newSnippet: false, + }; + }, + computed: { + updatePrevented() { + return this.snippet.title === '' || this.content === '' || this.isUpdating; + }, + isProjectSnippet() { + return Boolean(this.projectPath); + }, + apiData() { + return { + id: this.snippet.id, + title: this.snippet.title, + description: this.snippet.description, + visibilityLevel: this.snippet.visibilityLevel, + fileName: this.fileName, + content: this.content, + }; + }, + saveButtonLabel() { + if (this.newSnippet) { + return __('Create snippet'); + } + return this.isUpdating ? __('Saving') : __('Save changes'); + }, + cancelButtonHref() { + return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + }, + titleFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; + }, + descriptionFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; + }, + }, + methods: { + updateFileName(newName) { + this.fileName = newName; + }, + flashAPIFailure(err) { + Flash(sprintf(__("Can't update snippet: %{err}"), { err })); + }, + onNewSnippetFetched() { + this.newSnippet = true; + this.snippet = this.$options.newSnippetSchema; + this.blob = this.snippet.blob; + this.isContentLoading = false; + }, + onExistingSnippetFetched() { + this.newSnippet = false; + const { blob } = this.snippet; + this.blob = blob; + this.fileName = blob.name; + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, blob.rawPath); + + axios + .get(url) + .then(res => { + this.content = res.data; + this.isContentLoading = false; + }) + .catch(e => this.flashAPIFailure(e)); + }, + onSnippetFetch(snippetRes) { + if (snippetRes.data.snippets.edges.length === 0) { + this.onNewSnippetFetched(); + } else { + this.onExistingSnippetFetched(); + } + }, + handleFormSubmit() { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation, + variables: { + input: { + ...this.apiData, + projectPath: this.newSnippet ? this.projectPath : undefined, + }, + }, + }) + .then(({ data }) => { + const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; + + const errors = baseObj?.errors; + if (errors.length) { + this.flashAPIFailure(errors[0]); + } + redirectTo(baseObj.snippet.webUrl); + }) + .catch(e => { + this.isUpdating = false; + this.flashAPIFailure(e); + }); + }, + }, + newSnippetSchema: { + title: '', + description: '', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + blob: {}, + }, +}; +</script> +<template> + <form + class="snippet-form js-requires-input js-quick-submit common-note-form" + :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" + > + <gl-loading-icon + v-if="isLoading" + :label="__('Loading snippet')" + size="lg" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <template v-else> + <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" /> + <snippet-description-edit + :id="descriptionFieldId" + v-model="snippet.description" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + /> + <snippet-blob-edit + v-model="content" + :file-name="fileName" + :is-loading="isContentLoading" + @name-change="updateFileName" + /> + <snippet-visibility-edit + v-model="snippet.visibilityLevel" + :help-link="visibilityHelpLink" + :is-project-snippet="isProjectSnippet" + /> + <form-footer-actions> + <template #prepend> + <gl-button + type="submit" + category="primary" + variant="success" + :disabled="updatePrevented" + @click="handleFormSubmit" + >{{ saveButtonLabel }}</gl-button + > + </template> + <template #append> + <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button> + </template> + </form-footer-actions> + </template> + </form> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 68810f8ab3f..6f3a86be8d7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -50,7 +50,6 @@ export default { :markdown-docs-path="markdownDocsPath" > <textarea - id="snippet-description" slot="textarea" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" @@ -59,6 +58,7 @@ export default { :value="value" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" + v-bind="$attrs" @input="$emit('input', $event.target.value)" > </textarea> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 79b191cb25a..30a23b51bc4 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -9,7 +9,7 @@ import { GlLoadingIcon, GlDropdown, GlDropdownItem, - GlNewButton, + GlButton, } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -28,7 +28,7 @@ export default { GlDropdown, GlDropdownItem, TimeAgoTooltip, - GlNewButton, + GlButton, }, apollo: { canCreateSnippet: { @@ -186,7 +186,7 @@ export default { <div class="detail-page-header-actions"> <div class="d-none d-sm-flex"> <template v-for="(action, index) in personalSnippetActions"> - <gl-new-button + <gl-button v-if="action.condition" :key="index" :disabled="action.disabled" @@ -197,7 +197,7 @@ export default { @click="action.click ? action.click() : undefined" > {{ action.text }} - </gl-new-button> + </gl-button> </template> </div> <div class="d-block d-sm-none dropdown"> @@ -227,8 +227,8 @@ export default { </gl-sprintf> <template #modal-footer> - <gl-new-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-new-button> - <gl-new-button + <gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button> + <gl-button variant="danger" category="primary" :disabled="isDeleting" @@ -237,7 +237,7 @@ export default { > <gl-loading-icon v-if="isDeleting" inline /> {{ __('Delete snippet') }} - </gl-new-button> + </gl-button> </template> </gl-modal> </div> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index b826110117c..1c79492957d 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import SnippetsApp from './components/show.vue'; +import SnippetsShow from './components/show.vue'; +import SnippetsEdit from './components/edit.vue'; Vue.use(VueApollo); Vue.use(Translate); @@ -31,7 +32,11 @@ function appFactory(el, Component) { } export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsApp); + appFactory(document.getElementById('js-snippet-view'), SnippetsShow); +}; + +export const SnippetEditInit = () => { + appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); }; export default () => {}; diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql new file mode 100644 index 00000000000..f688868d1b9 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation CreateSnippet($input: CreateSnippetInput!) { + createSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql new file mode 100644 index 00000000000..548725f7357 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation UpdateSnippet($input: UpdateSnippetInput!) { + updateSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue new file mode 100644 index 00000000000..fef87057307 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue @@ -0,0 +1,29 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, +}; +</script> + +<template> + <div> + <h3>{{ s__('StaticSiteEditor|Incompatible file content') }}</h3> + <p> + {{ + s__( + 'StaticSiteEditor|The Static Site Editor is currently configured to only edit Markdown content on pages generated from Middleman. Visit the documentation to learn more about configuring your site to use the Static Site Editor.', + ) + }} + </p> + <div> + <gl-button + ref="documentationButton" + href="https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman" + >{{ s__('StaticSiteEditor|View documentation') }}</gl-button + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index efb442d4d09..274d2f71749 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -1,9 +1,9 @@ <script> -import { GlNewButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlNewButton, + GlButton, GlLoadingIcon, }, props: { @@ -29,16 +29,12 @@ export default { <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4"> <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" /> <div> - <gl-new-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{ + <gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') - }}</gl-new-button> - <gl-new-button - variant="success" - :disabled="!saveable || savingChanges" - @click="$emit('submit')" - > + }}</gl-button> + <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')"> {{ __('Submit Changes') }} - </gl-new-button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue index d76c6d9d681..41cb901720c 100644 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue @@ -1,14 +1,14 @@ <script> import { isString } from 'lodash'; -import { GlLink, GlNewButton } from '@gitlab/ui'; +import { GlLink, GlButton } from '@gitlab/ui'; const validateUrlAndLabel = value => isString(value.label) && isString(value.url); export default { components: { GlLink, - GlNewButton, + GlButton, }, props: { branch: { @@ -46,16 +46,12 @@ export default { }} </p> <div class="d-flex justify-content-end"> - <gl-new-button ref="returnToSiteButton" :href="returnUrl">{{ + <gl-button ref="returnToSiteButton" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') - }}</gl-new-button> - <gl-new-button - ref="mergeRequestButton" - class="ml-2" - :href="mergeRequest.url" - variant="success" - >{{ s__('StaticSiteEditor|View merge request') }}</gl-new-button - > + }}</gl-button> + <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success"> + {{ s__('StaticSiteEditor|View merge request') }} + </gl-button> </div> </div> @@ -64,7 +60,7 @@ export default { <ul> <li> {{ s__('StaticSiteEditor|You created a new branch:') }} - <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> + <span ref="branchLink">{{ branch.label }}</span> </li> <li> {{ s__('StaticSiteEditor|You created a merge request:') }} diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue index 4d912f5c0b5..82917319fc3 100644 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue @@ -4,14 +4,20 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import EditArea from './edit_area.vue'; import EditHeader from './edit_header.vue'; +import SavedChangesMessage from './saved_changes_message.vue'; import Toolbar from './publish_toolbar.vue'; +import InvalidContentMessage from './invalid_content_message.vue'; +import SubmitChangesError from './submit_changes_error.vue'; export default { components: { EditArea, EditHeader, + InvalidContentMessage, GlSkeletonLoader, + SavedChangesMessage, Toolbar, + SubmitChangesError, }, computed: { ...mapState([ @@ -19,44 +25,71 @@ export default { 'isLoadingContent', 'isSavingChanges', 'isContentLoaded', + 'isSupportedContent', 'returnUrl', 'title', + 'submitChangesError', + 'savedContentMeta', ]), ...mapGetters(['contentChanged']), }, mounted() { - this.loadContent(); + if (this.isSupportedContent) { + this.loadContent(); + } }, methods: { - ...mapActions(['loadContent', 'setContent', 'submitChanges']), + ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']), }, }; </script> <template> <div class="d-flex justify-content-center h-100 pt-2"> - <div v-if="isLoadingContent" class="w-50 h-50"> - <gl-skeleton-loader :width="500" :height="102"> - <rect width="500" height="16" rx="4" /> - <rect y="20" width="375" height="16" rx="4" /> - <rect x="380" y="20" width="120" height="16" rx="4" /> - <rect y="40" width="250" height="16" rx="4" /> - <rect x="255" y="40" width="150" height="16" rx="4" /> - <rect x="410" y="40" width="90" height="16" rx="4" /> - </gl-skeleton-loader> - </div> - <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> - <edit-header class="w-75 align-self-center py-2" :title="title" /> - <edit-area - class="w-75 h-100 shadow-none align-self-center" - :value="content" - @input="setContent" - /> - <toolbar - :return-url="returnUrl" - :saveable="contentChanged" - :saving-changes="isSavingChanges" - @submit="submitChanges" - /> - </div> + <!-- Success view --> + <saved-changes-message + v-if="savedContentMeta" + :branch="savedContentMeta.branch" + :commit="savedContentMeta.commit" + :merge-request="savedContentMeta.mergeRequest" + :return-url="returnUrl" + /> + + <!-- Main view --> + <template v-else-if="isSupportedContent"> + <div v-if="isLoadingContent" class="w-50 h-50"> + <gl-skeleton-loader :width="500" :height="102"> + <rect width="500" height="16" rx="4" /> + <rect y="20" width="375" height="16" rx="4" /> + <rect x="380" y="20" width="120" height="16" rx="4" /> + <rect y="40" width="250" height="16" rx="4" /> + <rect x="255" y="40" width="150" height="16" rx="4" /> + <rect x="410" y="40" width="90" height="16" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> + <submit-changes-error + v-if="submitChangesError" + class="w-75 align-self-center" + :error="submitChangesError" + @retry="submitChanges" + @dismiss="dismissSubmitChangesError" + /> + <edit-header class="w-75 align-self-center py-2" :title="title" /> + <edit-area + class="w-75 h-100 shadow-none align-self-center" + :value="content" + @input="setContent" + /> + <toolbar + :return-url="returnUrl" + :saveable="contentChanged" + :saving-changes="isSavingChanges" + @submit="submitChanges" + /> + </div> + </template> + + <!-- Error view --> + <invalid-content-message v-else class="w-75" /> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue new file mode 100644 index 00000000000..c5b6c685124 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue @@ -0,0 +1,24 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; + +export default { + components: { + GlAlert, + GlButton, + }, + props: { + error: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-alert variant="danger" dismissible @dismiss="$emit('dismiss')"> + {{ s__('StaticSiteEditor|An error occurred while submitting your changes.') }} {{ error }} + <template #actions> + <gl-button variant="danger" @click="$emit('retry')">{{ __('Retry') }}</gl-button> + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index c6a883c659a..15d668fd431 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -3,10 +3,17 @@ import StaticSiteEditor from './components/static_site_editor.vue'; import createStore from './store'; const initStaticSiteEditor = el => { - const { projectId, returnUrl, path: sourcePath } = el.dataset; + const { projectId, path: sourcePath, returnUrl } = el.dataset; + const isSupportedContent = 'isSupportedContent' in el.dataset; const store = createStore({ - initialState: { projectId, returnUrl, sourcePath, username: window.gon.current_username }, + initialState: { + isSupportedContent, + projectId, + returnUrl, + sourcePath, + username: window.gon.current_username, + }, }); return new Vue({ diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js index c57ef86f6ef..9f5e9e8c589 100644 --- a/app/assets/javascripts/static_site_editor/store/actions.js +++ b/app/assets/javascripts/static_site_editor/store/actions.js @@ -26,9 +26,12 @@ export const submitChanges = ({ state: { projectId, content, sourcePath, usernam return submitContentChanges({ content, projectId, sourcePath, username }) .then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data)) .catch(error => { - commit(mutationTypes.SUBMIT_CHANGES_ERROR); - createFlash(error.message); + commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message); }); }; +export const dismissSubmitChangesError = ({ commit }) => { + commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR); +}; + export default () => {}; diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js index 35eb35ebbe9..9cf356aecc5 100644 --- a/app/assets/javascripts/static_site_editor/store/mutation_types.js +++ b/app/assets/javascripts/static_site_editor/store/mutation_types.js @@ -5,3 +5,4 @@ export const SET_CONTENT = 'setContent'; export const SUBMIT_CHANGES = 'submitChanges'; export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess'; export const SUBMIT_CHANGES_ERROR = 'submitChangesError'; +export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError'; diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js index 4727d04439c..72fe71f1c9b 100644 --- a/app/assets/javascripts/static_site_editor/store/mutations.js +++ b/app/assets/javascripts/static_site_editor/store/mutations.js @@ -19,13 +19,18 @@ export default { }, [types.SUBMIT_CHANGES](state) { state.isSavingChanges = true; + state.submitChangesError = ''; }, [types.SUBMIT_CHANGES_SUCCESS](state, meta) { state.savedContentMeta = meta; state.isSavingChanges = false; state.originalContent = state.content; }, - [types.SUBMIT_CHANGES_ERROR](state) { + [types.SUBMIT_CHANGES_ERROR](state, error) { + state.submitChangesError = error; state.isSavingChanges = false; }, + [types.DISMISS_SUBMIT_CHANGES_ERROR](state) { + state.submitChangesError = ''; + }, }; diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js index 98a84d9f75d..8c524b4ffe9 100644 --- a/app/assets/javascripts/static_site_editor/store/state.js +++ b/app/assets/javascripts/static_site_editor/store/state.js @@ -6,6 +6,7 @@ const createState = (initialState = {}) => ({ isLoadingContent: false, isSavingChanges: false, + isSupportedContent: false, isContentLoaded: false, @@ -13,6 +14,7 @@ const createState = (initialState = {}) => ({ content: '', title: '', + submitChangesError: '', savedContentMeta: null, ...initialState, diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index f8c1c3634c2..bde00d72620 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -38,8 +38,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, - organization: userData.organization, - jobTitle: userData.job_title, + workInformation: userData.work_information, loaded: true, }); } @@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { const user = { location: null, bio: null, - organization: null, + workInformation: null, status: null, loaded: false, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue new file mode 100644 index 00000000000..848295cc984 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -0,0 +1,178 @@ +<script> +import { groupBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '~/locale'; + +// Internal constant, specific to this component, used when no `currentUserId` is given +const NO_USER_ID = -1; + +export default { + components: { + GlIcon, + }, + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, + }, + canAwardEmoji: { + type: Boolean, + required: true, + }, + currentUserId: { + type: Number, + required: false, + default: NO_USER_ID, + }, + addButtonClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + groupedAwards() { + const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name); + + return [ + ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []), + ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []), + ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)), + ]; + }, + isAuthoredByMe() { + return this.noteAuthorId === this.currentUserId; + }, + }, + methods: { + getAwardClassBindings(awardList) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: this.currentUserId === NO_USER_ID, + }; + }, + hasReactionByCurrentUser(awardList) { + if (this.currentUserId === NO_USER_ID) { + return false; + } + + return awardList.some(award => award.user.id === this.currentUserId); + }, + createAwardList(name, list) { + return { + name, + list, + title: this.getAwardListTitle(list), + classes: this.getAwardClassBindings(list), + html: glEmojiTag(name), + }; + }, + getAwardListTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.currentUserId); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the beginning of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift(__('You')); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = sprintf( + __(`%{listToShow}, and %{awardsListLength} more.`), + { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }, + false, + ); + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(__(' and ')); + } + + return title; + }, + handleAward(awardName) { + if (!this.canAwardEmoji) { + return; + } + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; + + this.$emit('award', parsedName); + }, + }, +}; +</script> + +<template> + <div class="awards js-awards-block"> + <button + v-for="awardList in groupedAwards" + :key="awardList.name" + v-tooltip + :class="awardList.classes" + :title="awardList.title" + data-boundary="viewport" + data-testid="award-button" + class="btn award-control" + type="button" + @click="handleAward(awardList.name)" + > + <span data-testid="award-html" v-html="awardList.html"></span> + <span class="award-control-text js-counter">{{ awardList.list.length }}</span> + </button> + <div v-if="canAwardEmoji" class="award-menu-holder"> + <button + v-tooltip + :class="addButtonClass" + class="award-control btn js-add-award" + title="Add reaction" + :aria-label="__('Add reaction')" + data-boundary="viewport" + type="button" + > + <span class="award-control-icon award-control-icon-neutral"> + <gl-icon aria-hidden="true" name="slight-smile" /> + </span> + <span class="award-control-icon award-control-icon-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <span class="award-control-icon award-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + ></i> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue index 3b9b9f37f52..7826c179889 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue @@ -3,7 +3,7 @@ import { GlNewDropdown, GlNewDropdownHeader, GlFormInputGroup, - GlNewButton, + GlButton, GlIcon, GlTooltipDirective, } from '@gitlab/ui'; @@ -15,7 +15,7 @@ export default { GlNewDropdown, GlNewDropdownHeader, GlFormInputGroup, - GlNewButton, + GlButton, GlIcon, }, directives: { @@ -55,13 +55,13 @@ export default { <div class="mx-3"> <gl-form-input-group :value="sshLink" readonly select-on-click> <template #append> - <gl-new-button + <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="sshLink" > <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-new-button> + </gl-button> </template> </gl-form-input-group> </div> @@ -73,13 +73,13 @@ export default { <div class="mx-3"> <gl-form-input-group :value="httpLink" readonly select-on-click> <template #append> - <gl-new-button + <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="httpLink" > <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-new-button> + </gl-button> </template> </gl-form-input-group> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index cdcd5cdef7f..ffc616d7309 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -158,7 +158,7 @@ export default { <template> <tooltip-on-truncate :title="timeWindowText" - :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')" + :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')" placement="top" class="d-inline-block" > diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index f8f70529bd1..fad69dc1e24 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -10,6 +10,6 @@ export default { </script> <template> <gl-form-group :label="__('Title')" label-for="title-field-edit"> - <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" /> + <gl-form-input v-bind="$attrs" v-on="$listeners" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 913c971a512..040a15406e0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -37,7 +37,7 @@ export default { :title="tooltipLabel" :class="cssClasses" type="button" - class="btn btn-blank gutter-toggle btn-sidebar-action" + class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" data-container="body" data-placement="left" data-boundary="viewport" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 602d4ab89e1..595baeeb14f 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,10 +1,8 @@ <script> -import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; -import { s__ } from '~/locale'; -import { isString } from 'lodash'; export default { name: 'UserPopover', @@ -12,7 +10,6 @@ export default { Icon, GlPopover, GlSkeletonLoading, - GlSprintf, UserAvatarImage, }, props: { @@ -49,26 +46,7 @@ export default { return !this.user.name; }, workInformationIsLoading() { - return !this.user.loaded && this.workInformation === null; - }, - workInformation() { - const { jobTitle, organization } = this.user; - - if (organization && jobTitle) { - return { - message: s__('Profile|%{job_title} at %{organization}'), - placeholders: { job_title: jobTitle, organization }, - }; - } else if (organization) { - return organization; - } else if (jobTitle) { - return jobTitle; - } - - return null; - }, - workInformationShouldUseSprintf() { - return !isString(this.workInformation); + return !this.user.loaded && this.user.workInformation === null; }, locationIsLoading() { return !this.user.loaded && this.user.location === null; @@ -98,23 +76,13 @@ export default { <icon name="profile" class="category-icon flex-shrink-0" /> <span ref="bio" class="ml-1">{{ user.bio }}</span> </div> - <div v-if="workInformation" class="d-flex mb-1"> + <div v-if="user.workInformation" class="d-flex mb-1"> <icon v-show="!workInformationIsLoading" name="work" class="category-icon flex-shrink-0" /> - <span ref="workInformation" class="ml-1"> - <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message"> - <template - v-for="(placeholder, slotName) in workInformation.placeholders" - v-slot:[slotName] - > - <span :key="slotName">{{ placeholder }}</span> - </template> - </gl-sprintf> - <span v-else>{{ workInformation }}</span> - </span> + <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> </div> <gl-skeleton-loading v-if="workInformationIsLoading" diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 6820bdca2fa..ce1039832d3 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -73,6 +73,11 @@ $item-weight-max-width: 48px; .issue-token-state-icon-closed { display: none; } + + .sortable-link { + color: $gray-900; + font-weight: normal; + } } .item-path-id .path-id-text, @@ -249,6 +254,12 @@ $item-weight-max-width: 48px; line-height: 0; } +@include media-breakpoint-down(xs) { + .btn-sm.dropdown-toggle-split { + max-width: 40px; + } +} + @include media-breakpoint-up(sm) { .item-info-area { flex-basis: 100%; @@ -296,10 +307,6 @@ $item-weight-max-width: 48px; .item-meta { .item-meta-child { flex-basis: unset; - - ~ .item-assignees { - margin-left: $gl-padding-4; - } } } @@ -353,7 +360,7 @@ $item-weight-max-width: 48px; } .item-title-wrapper { - max-width: calc(100% - 440px); + max-width: calc(100% - 500px); } .item-info-area { @@ -407,7 +414,7 @@ $item-weight-max-width: 48px; } } -@media only screen and (min-width: 1400px) { +@media only screen and (min-width: 1500px) { .card-header, .item-body { .health-label-short { @@ -419,7 +426,9 @@ $item-weight-max-width: 48px; } } - .item-body .item-title-wrapper { - max-width: calc(100% - 570px); + .item-body { + .item-title-wrapper { + max-width: calc(100% - 640px); + } } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index b1d79a41ba7..0292919ea50 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -105,10 +105,6 @@ } } - .js-ca-dropdown { - top: $gl-padding-top; - } - .stage-panel-body { display: flex; flex-wrap: wrap; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index af0afa9cc3b..f61245bed24 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -64,6 +64,12 @@ padding: $gl-padding-8 $gl-padding-12; } } + + .show-last-dropdown { + // same as in .dropdown-menu-toggle + // see app/assets/stylesheets/framework/dropdowns.scss + width: 160px; + } } .prometheus-panel { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 2a811e08fd3..b829a7b518e 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -87,6 +87,11 @@ .gl-bg-orange-100 { @include gl-bg-orange-100; } .gl-bg-gray-100 { @include gl-bg-gray-100; } .gl-bg-green-100 { @include gl-bg-green-100;} +.gl-bg-blue-500 { @include gl-bg-blue-500; } +.gl-bg-green-500 { @include gl-bg-green-500; } +.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; } +.gl-bg-red-500 { @include gl-bg-red-500; } +.gl-bg-orange-500 { @include gl-bg-orange-500; } .gl-text-blue-500 { @include gl-text-blue-500; } .gl-text-gray-500 { @include gl-text-gray-500; } diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 9eaa55039c8..4639d8adfe0 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -61,7 +61,15 @@ class Admin::RunnersController < Admin::ApplicationController end def runner_params - params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) + params.require(:runner).permit(permitted_attrs) + end + + def permitted_attrs + if Gitlab.com? + Ci::Runner::FORM_EDITABLE + Ci::Runner::MINUTES_COST_FACTOR_FIELDS + else + Ci::Runner::FORM_EDITABLE + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b2496427924..26ef6117e1c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -150,7 +150,7 @@ class ApplicationController < ActionController::Base payload[:username] = logged_user.try(:username) end - payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] + payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] end ## diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index 3ebd248c29e..de14bd319e0 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode) + params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol) end def cluster_application_destroy_params diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 825181568ad..d486d734db8 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication end def check_two_factor_requirement - if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? + if two_factor_authentication_required? && current_user_requires_two_factor? redirect_to profile_two_factor_auth_path end end @@ -27,6 +27,10 @@ module EnforcesTwoFactorAuthentication current_user.try(:ultraauth_user?) end + def current_user_requires_two_factor? + current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? + end + # rubocop: disable CodeReuse/ActiveRecord def two_factor_authentication_reason(global: -> {}, group: -> {}) if two_factor_authentication_required? @@ -61,3 +65,5 @@ module EnforcesTwoFactorAuthentication session[:skip_two_factor] && session[:skip_two_factor] > Time.current end end + +EnforcesTwoFactorAuthentication.prepend_if_ee('EE::EnforcesTwoFactorAuthentication') diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 4c998055a5d..ff283f9bb62 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -15,9 +15,7 @@ module IntegrationsActions end def update - integration.attributes = service_params[:service] - - saved = integration.save(context: :manual_change) + saved = integration.update(service_params[:service]) respond_to do |format| format.html do diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 039991e07a2..c173d7d2310 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -61,22 +61,24 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end end - # rubocop: disable CodeReuse/ActiveRecord def load_projects(finder_params) @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute finder_params[:use_cte] = true if use_cte_for_finder? - projects = ProjectsFinder - .new(params: finder_params, current_user: current_user) - .execute - .includes(:route, :creator, :group, namespace: [:route, :owner]) - .preload(:project_feature) - .page(finder_params[:page]) + projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute + + projects = preload_associations(projects) + projects = projects.page(finder_params[:page]) prepare_projects_for_rendering(projects) end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(projects) + projects.includes(:route, :creator, :group, namespace: [:route, :owner]).preload(:project_feature) + end # rubocop: enable CodeReuse/ActiveRecord def use_cte_for_finder? diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index a8a76b47bbe..705a586d614 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -66,18 +66,21 @@ class Explore::ProjectsController < Explore::ApplicationController @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute end - # rubocop: disable CodeReuse/ActiveRecord def load_projects load_project_counts - projects = ProjectsFinder.new(current_user: current_user, params: params) - .execute - .includes(:route, :creator, :group, namespace: [:route, :owner]) - .page(params[:page]) - .without_count + projects = ProjectsFinder.new(current_user: current_user, params: params).execute + + projects = preload_associations(projects) + projects = projects.page(params[:page]).without_count prepare_projects_for_rendering(projects) end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(projects) + projects.includes(:route, :creator, :group, namespace: [:route, :owner]) + end # rubocop: enable CodeReuse/ActiveRecord def set_sorting @@ -110,3 +113,5 @@ class Explore::ProjectsController < Explore::ApplicationController end end end + +Explore::ProjectsController.prepend_if_ee('EE::Explore::ProjectsController') diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index a478e9fffb8..8cfbd293597 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -5,6 +5,9 @@ class Groups::MilestonesController < Groups::ApplicationController before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] + before_action do + push_frontend_feature_flag(:burnup_charts) + end def index respond_to do |format| diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index 6b842fc9fe1..bfe7987176a 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -7,7 +7,7 @@ module Groups before_action :authorize_admin_group! before_action :authorize_update_max_artifacts_size!, only: [:update] before_action do - push_frontend_feature_flag(:new_variables_ui, @group) + push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true) push_frontend_feature_flag(:ajax_new_deploy_token, @group) end before_action :define_variables, only: [:show, :create_deploy_token] @@ -43,7 +43,7 @@ module Groups end def create_deploy_token - result = Projects::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute + result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute @new_deploy_token = result[:deploy_token] if result[:status] == :success diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 248b75d16ed..ebc81976529 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -13,16 +13,13 @@ class Projects::ForksController < Projects::ApplicationController before_action :authorize_fork_project!, only: [:new, :create] before_action :authorize_fork_namespace!, only: [:create] - # rubocop: disable CodeReuse/ActiveRecord def index @total_forks_count = project.forks.size @public_forks_count = project.forks.public_only.size @private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size @internal_forks_count = @total_forks_count - @public_forks_count - @private_forks_count - @forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute - @forks = @forks.includes(:route, :creator, :group, namespace: [:route, :owner]) - .page(params[:page]) + @forks = load_forks.page(params[:page]) prepare_projects_for_rendering(@forks) @@ -36,7 +33,6 @@ class Projects::ForksController < Projects::ApplicationController end end end - # rubocop: enable CodeReuse/ActiveRecord def new @namespaces = fork_service.valid_fork_targets - [project.namespace] @@ -59,10 +55,19 @@ class Projects::ForksController < Projects::ApplicationController redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked." end end - # rubocop: enable CodeReuse/ActiveRecord private + def load_forks + forks = ForkProjectsFinder.new( + project, + params: params.merge(search: params[:filter_projects]), + current_user: current_user + ).execute + + forks.includes(:route, :creator, :group, namespace: [:route, :owner]) + end + def fork_service strong_memoize(:fork_service) do ::Projects::ForkService.new(project, current_user, namespace: fork_namespace) @@ -83,3 +88,5 @@ class Projects::ForksController < Projects::ApplicationController Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335') end end + +Projects::ForksController.prepend_if_ee('EE::Projects::ForksController') diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb index 26d9b4b223f..4a70ed45404 100644 --- a/app/controllers/projects/import/jira_controller.rb +++ b/app/controllers/projects/import/jira_controller.rb @@ -11,11 +11,10 @@ module Projects before_action :authorize_admin_project!, only: [:import] def show - @is_jira_configured = @project.jira_service.present? - return if Feature.enabled?(:jira_issue_import_vue, @project) + jira_service = @project.jira_service - if !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project) - jira_client = @project.jira_service.client + if jira_service.present? && !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project) + jira_client = jira_service.client jira_projects = jira_client.Project.all if jira_projects.present? @@ -25,7 +24,9 @@ module Projects end end - flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial? + unless Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) + flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial? + end end def import @@ -50,7 +51,7 @@ module Projects end def jira_integration_configured? - return if Feature.enabled?(:jira_issue_import_vue, @project) + return if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) return if @project.jira_service flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." % diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 51ad8edb012..3aae8990f07 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController include RecordUserLastActivity def issue_except_actions - %i[index calendar new create bulk_update import_csv] + %i[index calendar new create bulk_update import_csv export_csv] end def set_issuables_index_only_actions @@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } - prepend_before_action :authenticate_user!, only: [:new] + prepend_before_action :authenticate_user!, only: [:new, :export_csv] # designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this prepend_before_action :store_uri, only: [:new, :show, :designs] @@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController end end + def export_csv + ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker + + index_path = project_issues_path(project) + redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.") + end + def import_csv if uploader = UploadService.new(project, params[:file]).execute ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 89de40006ff..cbab68b2827 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -25,6 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) push_frontend_feature_flag(:code_navigation, @project) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) + push_frontend_feature_flag(:merge_ref_head_comments, @project) end before_action do @@ -339,11 +340,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def serialize_widget(merge_request) - serializer.represent(merge_request, serializer: 'widget') + cached_data = serializer.represent(merge_request, serializer: 'poll_cached_widget') + widget_data = serializer.represent(merge_request, serializer: 'poll_widget') + cached_data.merge!(widget_data) end def serializer - MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + @serializer ||= MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end def define_edit_vars diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index d301a5be391..56f1f1a1019 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -6,6 +6,9 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :check_issuables_available! before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] + before_action do + push_frontend_feature_flag(:burnup_charts) + end # Allow read any milestone before_action :authorize_read_milestone! diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index a0f98d8f1d2..c7cd9649dac 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -6,8 +6,9 @@ module Projects before_action :authorize_admin_pipeline! before_action :define_variables before_action do - push_frontend_feature_flag(:new_variables_ui, @project) + push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true) push_frontend_feature_flag(:ajax_new_deploy_token, @project) + push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true) end def show diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 045aa38230c..bb20ea1de49 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -36,6 +36,10 @@ class ProjectsController < Projects::ApplicationController layout :determine_layout + before_action do + push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available) + end + def index redirect_to(current_user ? root_path : explore_root_path) end diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 9e134ba9526..118036de230 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -23,7 +23,7 @@ module Repositories # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack - enqueue_fetch_statistics_update + update_fetch_statistics render_ok end @@ -76,12 +76,16 @@ module Repositories render plain: exception.message, status: :service_unavailable end - def enqueue_fetch_statistics_update + def update_fetch_statistics + return unless project return if Gitlab::Database.read_only? return unless repo_type.project? - return unless project&.daily_statistics_enabled? - ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker + if Feature.enabled?(:project_statistics_sync, project, default_enabled: true) + Projects::FetchStatisticsIncrementService.new(project).execute + else + ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker + end end def access diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 06374736dcf..5ee97885071 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -128,6 +128,10 @@ class UsersController < ApplicationController @user ||= find_routable!(User, params[:username]) end + def personal_projects + PersonalProjectsFinder.new(user).execute(current_user) + end + def contributed_projects ContributedProjectsFinder.new(user).execute(current_user) end @@ -147,8 +151,7 @@ class UsersController < ApplicationController end def load_projects - @projects = - PersonalProjectsFinder.new(user).execute(current_user) + @projects = personal_projects .page(params[:page]) .per(params[:limit]) diff --git a/app/finders/autocomplete/move_to_project_finder.rb b/app/finders/autocomplete/move_to_project_finder.rb index af6defc1fc6..f1c1eacafe6 100644 --- a/app/finders/autocomplete/move_to_project_finder.rb +++ b/app/finders/autocomplete/move_to_project_finder.rb @@ -28,7 +28,8 @@ module Autocomplete .optionally_search(search, include_namespace: true) .excluding_project(project_id) .eager_load_namespace_and_owner - .sorted_by_name_asc_limited(LIMIT) + .sorted_by_stars_desc + .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord end end end diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb new file mode 100644 index 00000000000..b3f2693b273 --- /dev/null +++ b/app/finders/autocomplete/routes_finder.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Autocomplete + # Finder that returns a list of routes that match on the `path` attribute. + class RoutesFinder + attr_reader :current_user, :search + + LIMIT = 20 + + def initialize(current_user, params = {}) + @current_user = current_user + @search = params[:search] + end + + def execute + return [] if @search.blank? + + Route + .for_routable(routables) + .sort_by_path_length + .fuzzy_search(@search, [:path]) + .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord + end + + private + + def routables + raise NotImplementedError + end + + class NamespacesOnly < self + def routables + return Namespace.all if current_user.admin? + + current_user.namespaces + end + end + + class ProjectsOnly < self + def routables + return Project.all if current_user.admin? + + current_user.projects + end + end + end +end diff --git a/app/finders/metrics/dashboards/annotations_finder.rb b/app/finders/metrics/dashboards/annotations_finder.rb new file mode 100644 index 00000000000..c42b8bf40e5 --- /dev/null +++ b/app/finders/metrics/dashboards/annotations_finder.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Metrics + module Dashboards + class AnnotationsFinder + def initialize(dashboard:, params:) + @dashboard, @params = dashboard, params + end + + def execute + if dashboard.environment + apply_filters_to(annotations_for_environment) + else + Metrics::Dashboard::Annotation.none + end + end + + private + + attr_reader :dashboard, :params + + def apply_filters_to(annotations) + annotations = annotations.after(params[:from]) if params[:from].present? + annotations = annotations.before(params[:to]) if params[:to].present? && valid_timespan_boundaries? + + by_dashboard(annotations) + end + + def annotations_for_environment + dashboard.environment.metrics_dashboard_annotations + end + + def by_dashboard(annotations) + annotations.for_dashboard(dashboard.path) + end + + def valid_timespan_boundaries? + params[:from].blank? || params[:to] >= params[:from] + end + end + end +end diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb new file mode 100644 index 00000000000..068323a3073 --- /dev/null +++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + module Metrics + module Dashboards + class AnnotationResolver < Resolvers::BaseResolver + argument :from, Types::TimeType, + required: true, + description: "Timestamp marking date and time from which annotations need to be fetched" + + argument :to, Types::TimeType, + required: false, + description: "Timestamp marking date and time to which annotations need to be fetched" + + type Types::Metrics::Dashboards::AnnotationType, null: true + + alias_method :dashboard, :object + + def resolve(**args) + return [] unless dashboard + return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project) + + ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute + end + end + end + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 8f6b742a93c..cd4c6b4d46a 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -60,7 +60,7 @@ module Types description: 'Indicates if the source branch of the merge request will be deleted after merge' field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true, description: 'Indicates if the project settings will lead to source branch deletion after merge' - field :merge_status, GraphQL::STRING_TYPE, null: true, + field :merge_status, GraphQL::STRING_TYPE, method: :public_merge_status, null: true, description: 'Status of the merge request' field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true, description: 'Commit SHA of the merge request if merge is in progress' diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb index 11e834013ca..e7d09866bb5 100644 --- a/app/graphql/types/metrics/dashboard_type.rb +++ b/app/graphql/types/metrics/dashboard_type.rb @@ -9,6 +9,11 @@ module Types field :path, GraphQL::STRING_TYPE, null: true, description: 'Path to a file with the dashboard definition' + + field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true, + description: 'Annotations added to the dashboard. Will always return `null` ' \ + 'if `metrics_dashboard_annotations` feature flag is disabled', + resolver: Resolvers::Metrics::Dashboards::AnnotationResolver end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb new file mode 100644 index 00000000000..055d2544eff --- /dev/null +++ b/app/graphql/types/metrics/dashboards/annotation_type.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Metrics + module Dashboards + class AnnotationType < ::Types::BaseObject + authorize :read_metrics_dashboard_annotation + graphql_name 'MetricsDashboardAnnotation' + + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the annotation' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the annotation' + + field :panel_id, GraphQL::STRING_TYPE, null: true, + description: 'ID of a dashboard panel to which the annotation should be scoped' + + field :starting_at, GraphQL::STRING_TYPE, null: true, + description: 'Timestamp marking start of annotated time span' + + field :ending_at, GraphQL::STRING_TYPE, null: true, + description: 'Timestamp marking end of annotated time span' + + def panel_id + object.panel_xid + end + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 3115a53e053..8356e763be9 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -26,7 +26,7 @@ module Types markdown_field :description_html, null: true field :tag_list, GraphQL::STRING_TYPE, null: true, - description: 'List of project tags' + description: 'List of project topics (not Git tags)' field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true, description: 'URL to connect to the project via SSH' diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 8a79217c929..070089d6ef8 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -9,19 +9,6 @@ module PreferencesHelper ] end - # Maps `dashboard` values to more user-friendly option text - DASHBOARD_CHOICES = { - projects: _("Your Projects (default)"), - stars: _("Starred Projects"), - project_activity: _("Your Projects' Activity"), - starred_project_activity: _("Starred Projects' Activity"), - groups: _("Your Groups"), - todos: _("Your To-Do List"), - issues: _("Assigned Issues"), - merge_requests: _("Assigned Merge Requests"), - operations: _("Operations Dashboard") - }.with_indifferent_access.freeze - # Returns an Array usable by a select field for more user-friendly option text def dashboard_choices dashboards = User.dashboards.keys @@ -31,10 +18,25 @@ module PreferencesHelper dashboards.map do |key| # Use `fetch` so `KeyError` gets raised when a key is missing - [DASHBOARD_CHOICES.fetch(key), key] + [localized_dashboard_choices.fetch(key), key] end end + # Maps `dashboard` values to more user-friendly option text + def localized_dashboard_choices + { + projects: _("Your Projects (default)"), + stars: _("Starred Projects"), + project_activity: _("Your Projects' Activity"), + starred_project_activity: _("Starred Projects' Activity"), + groups: _("Your Groups"), + todos: _("Your To-Do List"), + issues: _("Assigned Issues"), + merge_requests: _("Assigned Merge Requests"), + operations: _("Operations Dashboard") + }.with_indifferent_access.freeze + end + def project_view_choices [ ['Files and Readme (default)', :files], @@ -75,9 +77,9 @@ module PreferencesHelper # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too def validate_dashboard_choices!(user_dashboards) - if user_dashboards.size != DASHBOARD_CHOICES.size + if user_dashboards.size != localized_dashboard_choices.size raise "`User` defines #{user_dashboards.size} dashboard choices," \ - " but `DASHBOARD_CHOICES` defined #{DASHBOARD_CHOICES.size}." + " but `localized_dashboard_choices` defined #{localized_dashboard_choices.size}." end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e700f0dbf2a..3d5f22faf68 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -737,3 +737,5 @@ module ProjectsHelper can?(current_user, :destroy_container_image, project) end end + +ProjectsHelper.prepend_if_ee('EE::ProjectsHelper') diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 3fd865003c1..d4d93ab9795 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -91,6 +91,20 @@ module Emails end end + def issues_csv_email(user, project, csv_data, export_status) + @project = project + @issues_count = export_status.fetch(:rows_expected) + @written_count = export_status.fetch(:rows_written) + @truncated = export_status.fetch(:truncated) + + filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv" + attachments[filename] = { content: csv_data, mime_type: 'text/csv' } + mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end + private def setup_issue_mail(issue_id, recipient_id, closed_via: nil) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 114737eb232..38e1d9532a6 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true }) end + def issues_csv_email + Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message + end + def closed_merge_request_email Notify.closed_merge_request_email(user.id, issue.id, user.id).message end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index fa0619f35b0..76882dfcb0d 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -3,7 +3,6 @@ module Ci class Bridge < Ci::Processable include Ci::Contextable - include Ci::PipelineDelegator include Ci::Metadatable include Importable include AfterCommitQueue diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 74a329dccf4..8bc75b6c164 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -4,7 +4,6 @@ module Ci class Build < Ci::Processable include Ci::Metadatable include Ci::Contextable - include Ci::PipelineDelegator include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -526,6 +525,7 @@ module Ci strong_memoize(:variables) do Gitlab::Ci::Variables::Collection.new .concat(persisted_variables) + .concat(job_jwt_variables) .concat(scoped_variables) .concat(job_variables) .concat(environment_changed_page_variables) @@ -591,13 +591,7 @@ module Ci def merge_request strong_memoize(:merge_request) do - merge_requests = MergeRequest.includes(:latest_merge_request_diff) - .where(source_branch: ref, source_project: pipeline.project) - .reorder(iid: :desc) - - merge_requests.find do |merge_request| - merge_request.commit_shas.include?(pipeline.sha) - end + pipeline.all_merge_requests.order(iid: :asc).first end end @@ -981,6 +975,15 @@ module Ci def has_expiring_artifacts? artifacts_expire_at.present? && artifacts_expire_at > Time.now end + + def job_jwt_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Feature.enabled?(:ci_job_jwt, project, default_enabled: true) + + jwt = Gitlab::Ci::Jwt.for_build(self) + variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true) + end + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index c4ac10814a9..ef0701b3874 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -73,14 +73,12 @@ module Ci validates :file_format, presence: true, unless: :trace?, on: :create validate :valid_file_format?, unless: :trace?, on: :create - before_save :set_size, if: :file_changed? - before_save :set_file_store, if: ->(job_artifact) { job_artifact.file_store.nil? } - - after_save :update_file_store, if: :saved_change_to_file? update_project_statistics project_statistics_name: :build_artifacts_size + after_save :update_file_store, if: :saved_change_to_file? + scope :with_files_stored_locally, -> { where(file_store: [nil, ::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 }) } @@ -228,15 +226,6 @@ module Ci self.size = file.size end - def set_file_store - self.file_store = - if JobArtifactUploader.object_store_enabled? && JobArtifactUploader.direct_upload_enabled? - JobArtifactUploader::Store::REMOTE - else - file.object_store - end - end - def project_destroyed? # Use job.project to avoid extra DB query for project job.project.pending_delete? diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 4bc8f26ec92..c123bd7c33b 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -51,6 +51,12 @@ module Ci validates :type, presence: true validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type? + delegate :merge_request?, + :merge_request_ref?, + :legacy_detached_merge_request_pipeline?, + :merge_train_pipeline?, + to: :pipeline + def aggregated_needs_names read_attribute(:aggregated_needs_names) end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 690aa978716..d4e9217ff9f 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -35,6 +35,7 @@ module Ci AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze + MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6' @@ -137,6 +138,11 @@ module Ci numericality: { greater_than_or_equal_to: 600, message: 'needs to be at least 10 minutes' } + validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor, + allow_nil: false, + numericality: { greater_than_or_equal_to: 0.0, + message: 'needs to be non-negative' } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb new file mode 100644 index 00000000000..a33b1e39ace --- /dev/null +++ b/app/models/clusters/applications/fluentd.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class Fluentd < ApplicationRecord + VERSION = '2.4.0' + + self.table_name = 'clusters_applications_fluentd' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + include ::Clusters::Concerns::ApplicationVersion + include ::Clusters::Concerns::ApplicationData + + default_value_for :version, VERSION + default_value_for :port, 514 + default_value_for :protocol, :tcp + + enum protocol: { tcp: 0, udp: 1 } + + def chart + 'stable/fluentd' + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'fluentd', + repository: repository, + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ) + end + + def values + content_values.to_yaml + end + + private + + def content_values + YAML.load_file(chart_values_file).deep_merge!(specification) + end + + def specification + { + "configMaps" => { + "output.conf" => output_configuration_content, + "general.conf" => general_configuration_content + } + } + end + + def output_configuration_content + <<~EOF + <match kubernetes.**> + @type remote_syslog + @id out_kube_remote_syslog + host #{host} + port #{port} + program fluentd + hostname ${kubernetes_host} + protocol #{protocol} + packet_size 65535 + <buffer kubernetes_host> + </buffer> + <format> + @type ltsv + </format> + </match> + EOF + end + + def general_configuration_content + <<~EOF + <match fluent.**> + @type null + </match> + <source> + @type http + port 9880 + bind 0.0.0.0 + </source> + <source> + @type tail + @id in_tail_container_logs + path /var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log + pos_file /var/log/fluentd-containers.log.pos + tag kubernetes.* + read_from_head true + <parse> + @type json + time_format %Y-%m-%dT%H:%M:%S.%NZ + </parse> + </source> + EOF + end + end + end +end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index baf34e916f8..5985e08d73e 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -30,7 +30,6 @@ module Clusters enum modsecurity_mode: { logging: 0, blocking: 1 } FETCH_IP_ADDRESS_DELAY = 30.seconds - MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10 state_machine :status do after_transition any => [:installed] do |application| @@ -108,11 +107,13 @@ module Clusters "readOnly" => true } ], - "startupProbe" => { + "livenessProbe" => { "exec" => { - "command" => ["ls", "/var/log/modsec"] - }, - "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS + "command" => [ + "ls", + "/var/log/modsec/audit.log" + ] + } } } ], diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 9ef3d64f21a..430a9b3c43e 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -19,7 +19,8 @@ module Clusters Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, - Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack + Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack, + Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' @@ -57,6 +58,7 @@ module Clusters has_one_cluster_application :jupyter has_one_cluster_application :knative has_one_cluster_application :elastic_stack + has_one_cluster_application :fluentd has_many :kubernetes_namespaces has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster diff --git a/app/models/concerns/ci/has_ref.rb b/app/models/concerns/ci/has_ref.rb index cf57ff47743..e2d459ea70e 100644 --- a/app/models/concerns/ci/has_ref.rb +++ b/app/models/concerns/ci/has_ref.rb @@ -2,7 +2,7 @@ ## # We will disable `ref` and `sha` attributes in `Ci::Build` in the future -# and remove this module in favor of Ci::PipelineDelegator. +# and remove this module in favor of Ci::Processable. module Ci module HasRef extend ActiveSupport::Concern diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb deleted file mode 100644 index 68ad0fcee31..00000000000 --- a/app/models/concerns/ci/pipeline_delegator.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -## -# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up -# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves -# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as -# the system could behave differently time to time. -# We should have a single interface in `Ci::Pipeline` and access the method always. -module Ci - module PipelineDelegator - extend ActiveSupport::Concern - - included do - delegate :merge_request?, - :merge_request_ref?, - :legacy_detached_merge_request_pipeline?, - :merge_train_pipeline?, to: :pipeline - end - end -end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7300283f086..37f2209b9d2 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -116,6 +116,7 @@ module Issuable # rubocop:enable GitlabSecurity/SqlInjection scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } + scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } @@ -131,8 +132,21 @@ module Issuable strip_attributes :title - def self.locking_enabled? - false + class << self + def labels_hash + issue_labels = Hash.new { |h, k| h[k] = [] } + + relation = unscoped.where(id: self.select(:id)).eager_load(:labels) + relation.pluck(:id, 'labels.title').each do |issue_id, label| + issue_labels[issue_id] << label if label.present? + end + + issue_labels + end + + def locking_enabled? + false + end end # We want to use optimistic lock for cases when only title or description are involved @@ -478,5 +492,4 @@ module Issuable end end -Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule -Issuable::ClassMethods.prepend_if_ee('EE::Issuable::ClassMethods') +Issuable.prepend_if_ee('EE::Issuable') diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index 7f00b652530..2354335469a 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -6,12 +6,14 @@ module NotificationBranchSelection extend ActiveSupport::Concern - BRANCH_CHOICES = [ - [_('All branches'), 'all'], - [_('Default branch'), 'default'], - [_('Protected branches'), 'protected'], - [_('Default branch and protected branches'), 'default_and_protected'] - ].freeze + def branch_choices + [ + [_('All branches'), 'all'].freeze, + [_('Default branch'), 'default'].freeze, + [_('Protected branches'), 'protected'].freeze, + [_('Default branch and protected branches'), 'default_and_protected'].freeze + ].freeze + end def notify_for_branch?(data) ref = if data[:ref] diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 76d26500267..cedcf164a49 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -66,6 +66,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:pages_access_level, value) end + def metrics_dashboard_access_level=(value) + write_feature_attribute_string(:metrics_dashboard_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 93e3ebf7896..f9e2f00b9f3 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -13,6 +13,7 @@ class DiffDiscussion < Discussion delegate :position, :original_position, :change_position, + :diff_note_positions, :on_text?, :on_image?, diff --git a/app/models/diff_note_position.rb b/app/models/diff_note_position.rb index 716a56c6430..a25b0def643 100644 --- a/app/models/diff_note_position.rb +++ b/app/models/diff_note_position.rb @@ -2,6 +2,7 @@ class DiffNotePosition < ApplicationRecord belongs_to :note + attr_accessor :line_range enum diff_content_type: { text: 0, @@ -42,6 +43,7 @@ class DiffNotePosition < ApplicationRecord def self.position_to_attrs(position) position_attrs = position.to_h position_attrs[:diff_content_type] = position_attrs.delete(:position_type) + position_attrs.delete(:line_range) position_attrs end end diff --git a/app/models/group.rb b/app/models/group.rb index f4eaa581d54..55a2c4ba9a9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -72,7 +72,7 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :name, format: { with: Gitlab::Regex.group_name_regex, - message: Gitlab::Regex.group_name_regex_message } + message: Gitlab::Regex.group_name_regex_message }, if: :name_changed? add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required } diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb index a1e03218640..109c0c82487 100644 --- a/app/models/import_failure.rb +++ b/app/models/import_failure.rb @@ -6,4 +6,11 @@ class ImportFailure < ApplicationRecord validates :project, presence: true, unless: :group validates :group, presence: true, unless: :project + + # Returns any `import_failures` for relations that were unrecoverable errors or failed after + # several retries. An import can be successful even if some relations failed to import correctly. + # A retry_count of 0 indicates that either no retries were attempted, or they were exceeded. + scope :hard_failures_by_correlation_id, ->(correlation_id) { + where(correlation_id_value: correlation_id, retry_count: 0).order(created_at: :desc) + } end diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb index 543ee77917c..bde2795e7b8 100644 --- a/app/models/jira_import_state.rb +++ b/app/models/jira_import_state.rb @@ -53,6 +53,7 @@ class JiraImportState < ApplicationRecord before_transition any => :finished do |state, _| InternalId.flush_records!(project: state.project) state.project.update_project_counter_caches + state.store_issue_counts end after_transition any => :finished do |state, _| @@ -80,4 +81,20 @@ class JiraImportState < ApplicationRecord def non_initial? !initial? end + + def store_issue_counts + import_label_id = Gitlab::JiraImport.get_import_label_id(project.id) + + failed_to_import_count = Gitlab::JiraImport.issue_failures(project.id) + successfully_imported_count = project.issues.with_label_ids(import_label_id).count + total_issue_count = successfully_imported_count + failed_to_import_count + + update( + { + failed_to_import_count: failed_to_import_count, + imported_issues_count: successfully_imported_count, + total_issue_count: total_issue_count + } + ) + end end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index c5233deaa96..6a86aebae39 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -17,8 +17,6 @@ class LfsObject < ApplicationRecord mount_uploader :file, LfsObjectUploader - before_save :set_file_store, if: ->(lfs_object) { lfs_object.file_store.nil? } - after_save :update_file_store, if: :saved_change_to_file? def self.not_linked_to_project(project) @@ -57,17 +55,6 @@ class LfsObject < ApplicationRecord def self.calculate_oid(path) self.hexdigest(path) end - - private - - def set_file_store - self.file_store = - if LfsObjectUploader.object_store_enabled? && LfsObjectUploader.direct_upload_enabled? - LfsObjectUploader::Store::REMOTE - else - file.object_store - end - end end LfsObject.prepend_if_ee('EE::LfsObject') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b9acb539404..9939167e74f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -167,20 +167,22 @@ class MergeRequest < ApplicationRecord end event :mark_as_checking do - transition [:unchecked, :cannot_be_merged_recheck] => :checking + transition unchecked: :checking + transition cannot_be_merged_recheck: :cannot_be_merged_rechecking end event :mark_as_mergeable do - transition [:unchecked, :cannot_be_merged_recheck, :checking] => :can_be_merged + transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :can_be_merged end event :mark_as_unmergeable do - transition [:unchecked, :cannot_be_merged_recheck, :checking] => :cannot_be_merged + transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :cannot_be_merged end state :unchecked state :cannot_be_merged_recheck state :checking + state :cannot_be_merged_rechecking state :can_be_merged state :cannot_be_merged @@ -189,7 +191,7 @@ class MergeRequest < ApplicationRecord end # rubocop: disable CodeReuse/ServiceClass - after_transition unchecked: :cannot_be_merged do |merge_request, transition| + after_transition [:unchecked, :checking] => :cannot_be_merged do |merge_request, transition| if merge_request.notify_conflict? NotificationService.new.merge_request_unmergeable(merge_request) TodoService.new.merge_request_became_unmergeable(merge_request) @@ -202,6 +204,12 @@ class MergeRequest < ApplicationRecord end end + # Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking` + # to avoid exposing unnecessary internal state + def public_merge_status + cannot_be_merged_rechecking? ? 'checking' : merge_status + end + validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?] validates :source_branch, presence: true validates :target_project, presence: true @@ -569,13 +577,13 @@ class MergeRequest < ApplicationRecord merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size end - def modified_paths(past_merge_request_diff: nil) + def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) if past_merge_request_diff - past_merge_request_diff.modified_paths + past_merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) elsif compare diff_stats&.paths || compare.modified_paths else - merge_request_diff.modified_paths + merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 9136c6cc5d4..7b15d21c095 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -366,9 +366,22 @@ class MergeRequestDiff < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def modified_paths - strong_memoize(:modified_paths) do - merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq + def modified_paths(fallback_on_overflow: false) + if fallback_on_overflow && overflow? + # This is an extremely slow means to find the modified paths for a given + # MergeRequestDiff. This should be avoided, except where the limit of + # 1_000 (as of %12.10) entries returned by the default behavior is an + # issue. + strong_memoize(:overflowed_modified_paths) do + project.repository.diff_stats( + base_commit_sha, + head_commit_sha + ).paths + end + else + strong_memoize(:modified_paths) do + merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq + end end end diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb index 2f1b6527742..8166880f0c9 100644 --- a/app/models/metrics/dashboard/annotation.rb +++ b/app/models/metrics/dashboard/annotation.rb @@ -15,6 +15,11 @@ module Metrics validate :single_ownership validate :orphaned_annotation + scope :after, ->(after) { where('starting_at >= ?', after) } + scope :before, ->(before) { where('starting_at <= ?', before) } + + scope :for_dashboard, ->(dashboard_path) { where(dashboard_path: dashboard_path) } + private def single_ownership diff --git a/app/models/note.rb b/app/models/note.rb index e6ad7c2227f..a2a711c987f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -125,7 +125,7 @@ class Note < ApplicationRecord scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, - { system_note_metadata: :description_version }, :note_diff_file, :suggestions) + { system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions) end scope :with_notes_filter, -> (notes_filter) do diff --git a/app/models/project.rb b/app/models/project.rb index 443b44dd023..79785bfce85 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -340,7 +340,7 @@ class Project < ApplicationRecord :pages_enabled?, :public_pages?, :private_pages?, :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, :pages_access_level, + :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, to: :project_feature, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true @@ -415,7 +415,6 @@ class Project < ApplicationRecord scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) } scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) } - scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) } # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } @@ -591,7 +590,7 @@ class Project < ApplicationRecord # # query - The search query as a String. def search(query, include_namespace: false) - if include_namespace && Feature.enabled?(:project_search_by_full_path, default_enabled: true) + if include_namespace joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description]) else fuzzy_search(query, [:path, :name, :description]) @@ -774,10 +773,6 @@ class Project < ApplicationRecord { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) } end - def daily_statistics_enabled? - Feature.enabled?(:project_daily_statistics, self, default_enabled: true) - end - def unlink_forks_upon_visibility_decrease_enabled? Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true) end @@ -866,6 +861,16 @@ class Project < ApplicationRecord latest_jira_import&.status || 'initial' end + def validate_jira_import_settings!(user: nil) + raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled? + raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active? + + return unless user + + raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user) + raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self) + end + def human_import_status_name import_state&.human_status_name || 'none' end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index a9753c3c53a..31a3fa12c00 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord ENABLED = 20 PUBLIC = 30 - FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze STRING_OPTIONS = HashWithIndifferentAccess.new({ @@ -90,13 +90,14 @@ class ProjectFeature < ApplicationRecord validate :repository_children_level validate :allowed_access_levels - default_value_for :builds_access_level, value: ENABLED, allows_nil: false - default_value_for :issues_access_level, value: ENABLED, allows_nil: false - default_value_for :forking_access_level, value: ENABLED, allows_nil: false - default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false - default_value_for :snippets_access_level, value: ENABLED, allows_nil: false - default_value_for :wiki_access_level, value: ENABLED, allows_nil: false - default_value_for :repository_access_level, value: ENABLED, allows_nil: false + default_value_for :builds_access_level, value: ENABLED, allows_nil: false + default_value_for :issues_access_level, value: ENABLED, allows_nil: false + default_value_for :forking_access_level, value: ENABLED, allows_nil: false + default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false + default_value_for :snippets_access_level, value: ENABLED, allows_nil: false + default_value_for :wiki_access_level, value: ENABLED, allows_nil: false + default_value_for :repository_access_level, value: ENABLED, allows_nil: false + default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index f58b8dc624d..e434ea58729 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -72,6 +72,10 @@ class ProjectImportState < ApplicationRecord end end + def relation_hard_failures(limit:) + project.import_failures.hard_failures_by_correlation_id(correlation_id).limit(limit) + end + def mark_as_failed(error_message) original_errors = errors.dup sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 1ec983223f3..c9e97efb4ac 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -59,11 +59,11 @@ class ChatNotificationService < Service def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }, - { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } - ] + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze + ].freeze end def execute(data) diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb index 294b286f073..941b7f64263 100644 --- a/app/models/project_services/discord_service.rb +++ b/app/models/project_services/discord_service.rb @@ -44,7 +44,7 @@ class DiscordService < ChatNotificationService [ { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, { type: "checkbox", name: "notify_only_broken_pipelines" }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index dd2f1359e76..01d8647d439 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -66,7 +66,7 @@ class EmailsOnPushService < Service help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } }, { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') } ] end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index d105bd012d6..299a306add7 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -44,7 +44,7 @@ class HangoutsChatService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 3f7e8a720aa..f5d6ae10469 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -172,7 +172,7 @@ class IssueTrackerService < Service end def one_issue_tracker - return if template? + return if template? || instance? return if project.blank? if project.services.external_issue_trackers.where.not(id: id).any? diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 111d010d672..e8e12a9a206 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -42,7 +42,7 @@ class MicrosoftTeamsService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index b5e5afb6ea5..a58a264de5e 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -72,7 +72,7 @@ class PipelinesEmailService < Service name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', - choices: BRANCH_CHOICES } + choices: branch_choices } ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 1a85289a04f..4a28d1ff2b0 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -36,10 +36,6 @@ class PrometheusService < MonitoringService false end - def editable? - manual_configuration? || !prometheus_available? - end - def title 'Prometheus' end @@ -53,8 +49,6 @@ class PrometheusService < MonitoringService end def fields - return [] unless editable? - [ { type: 'checkbox', diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb index 06f2d10f83b..1e12179e62a 100644 --- a/app/models/project_services/unify_circuit_service.rb +++ b/app/models/project_services/unify_circuit_service.rb @@ -38,7 +38,7 @@ class UnifyCircuitService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 8e66310f0c5..cd47c154eef 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -56,7 +56,7 @@ class ResourceLabelEvent < ResourceEvent end def banzai_render_context(field) - super.merge(pipeline: 'label', only_path: true) + super.merge(pipeline: :label, only_path: true) end def refresh_invalid_reference diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index b97c02f1713..a40af22061e 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -13,9 +13,9 @@ class ResourceMilestoneEvent < ResourceEvent validate :exactly_one_issuable enum action: { - add: 1, - remove: 2 - } + add: 1, + remove: 2 + } # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states) diff --git a/app/models/route.rb b/app/models/route.rb index 91ea2966013..63a0461807b 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -2,9 +2,9 @@ class Route < ApplicationRecord include CaseSensitivity + include Gitlab::SQL::Pattern belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations - validates :source, presence: true validates :path, @@ -19,6 +19,8 @@ class Route < ApplicationRecord after_update :rename_descendants scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } + scope :for_routable, -> (routable) { where(source: routable) } + scope :sort_by_path_length, -> { order('LENGTH(routes.path)', :path) } def rename_descendants return unless saved_change_to_path? || saved_change_to_name? diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 8ca4ee9239a..c4e047ff9d1 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -2,14 +2,25 @@ module Terraform class State < ApplicationRecord + DEFAULT = '{"version":1}'.freeze + HEX_REGEXP = %r{\A\h+\z}.freeze + UUID_LENGTH = 32 + belongs_to :project + belongs_to :locked_by_user, class_name: 'User' validates :project_id, presence: true + validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, + format: { with: HEX_REGEXP, message: 'only allows hex characters' } + + default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } after_save :update_file_store, if: :saved_change_to_file? mount_uploader :file, StateUploader + default_value_for(:file) { CarrierWaveStringFile.new(DEFAULT) } + def update_file_store # The file.object_store is set during `uploader.store!` # which happens after object is inserted/updated @@ -19,5 +30,9 @@ module Terraform def file_store super || StateUploader.default_store end + + def locked? + self.lock_xid.present? + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 42972477d97..1b087da3a2f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -337,7 +337,8 @@ class User < ApplicationRecord scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } scope :bots, -> { where(user_type: UserTypeEnums.bots.values) } - scope :not_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.values)) } + scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) } + scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) } scope :humans, -> { where(user_type: nil) } scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do @@ -657,8 +658,10 @@ class User < ApplicationRecord UserTypeEnums.bots.has_key?(user_type) end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def internal? - ghost? || bot? + ghost? || (bot? && !project_bot?) end # We are transitioning from ghost boolean column to user_type @@ -668,12 +671,16 @@ class User < ApplicationRecord ghost end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def self.internal - where(ghost: true).or(bots) + where(ghost: true).or(bots_without_project_bot) end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def self.non_internal - without_ghosts.not_bots + without_ghosts.with_project_bots end # @@ -1720,7 +1727,7 @@ class User < ApplicationRecord # override, from Devise::Validatable def password_required? - return false if internal? + return false if internal? || project_bot? super end diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb index 795cc4b2889..cb5aac89ed3 100644 --- a/app/models/user_type_enums.rb +++ b/app/models/user_type_enums.rb @@ -6,7 +6,7 @@ module UserTypeEnums end def self.bots - @bots ||= { alert_bot: 2 }.with_indifferent_access + @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 2bde7bcca08..9353b361c2a 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -17,6 +17,8 @@ class GlobalPolicy < BasePolicy condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? } + condition(:project_bot, scope: :user) { @user&.project_bot? } + rule { admin | (~private_instance_statistics & ~anonymous) } .enable :read_instance_statistics @@ -51,6 +53,11 @@ class GlobalPolicy < BasePolicy prevent :use_slash_commands end + rule { project_bot }.policy do + prevent :log_in + prevent :receive_notifications + end + rule { deactivated }.policy do prevent :access_git prevent :access_api diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index a34217d90dd..728c4b76498 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -91,6 +91,7 @@ class GroupPolicy < BasePolicy end rule { reporter }.policy do + enable :reporter_access enable :read_container_image enable :download_wiki_code enable :admin_label diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index ce9a3346b4b..395eaeea8de 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -36,16 +36,18 @@ module Ci end end - NAMES = { - merge_train: s_('Pipeline|Merge train pipeline'), - merged_result: s_('Pipeline|Merged result pipeline'), - detached: s_('Pipeline|Detached merge request pipeline') - }.freeze + def localized_names + { + merge_train: s_('Pipeline|Merge train pipeline'), + merged_result: s_('Pipeline|Merged result pipeline'), + detached: s_('Pipeline|Detached merge request pipeline') + }.freeze + end def name # Currently, `merge_request_event_type` is the only source to name pipelines # but this could be extended with the other types in the future. - NAMES.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline')) + localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline')) end def ref_text diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index b9797bfb021..57e9225e2da 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -4,4 +4,12 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true expose :title expose :unit, if: { with_unit: true } + + private + + def value + return object.value if object.value.is_a? String + + object.value&.nonzero? ? object.value.to_s : '-' + end end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index c08691c6bcf..85a40f1f5cb 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -16,4 +16,7 @@ class ClusterApplicationEntity < Grape::Entity expose :available_domains, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:available_domains) } expose :pages_domain, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:pages_domain) } expose :modsecurity_mode, if: -> (e, _) { e.respond_to?(:modsecurity_mode) } + expose :host, if: -> (e, _) { e.respond_to?(:host) } + expose :port, if: -> (e, _) { e.respond_to?(:port) } + expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) } end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index e302672042e..77881eaba0c 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -20,6 +20,14 @@ class DiscussionEntity < Grape::Entity discussion_path(discussion) end + expose :positions, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion| + discussion.diff_note_positions.map(&:position) + end + + expose :line_codes, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion| + discussion.diff_note_positions.map(&:line_code) + end + expose :individual_note?, as: :individual_note expose :resolvable do |discussion| discussion.resolvable? @@ -59,4 +67,11 @@ class DiscussionEntity < Grape::Entity def current_user request.current_user end + + def display_merge_ref_discussions?(discussion) + return unless discussion.diff_discussion? + return if discussion.legacy_diff_discussion? + + Feature.enabled?(:merge_ref_head_comments, discussion.project) + end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 973e971b4c0..82baf4a4a78 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MergeRequestBasicEntity < Grape::Entity - expose :merge_status + expose :public_merge_status, as: :merge_status expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 2f8eb6650e8..72f629b3507 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -6,7 +6,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity expose :merge_commit_sha expose :short_merge_commit_sha expose :merge_error - expose :merge_status + expose :public_merge_status, as: :merge_status expose :merge_user_id expose :source_branch expose :source_project_id diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa67cd1f39e..9fd50c8c51d 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -15,6 +15,10 @@ class MergeRequestSerializer < BaseSerializer MergeRequestBasicEntity when 'noteable' MergeRequestNoteableEntity + when 'poll_cached_widget' + MergeRequestPollCachedWidgetEntity + when 'poll_widget' + MergeRequestPollWidgetEntity else # fallback to widget for old poll requests without `serializer` set MergeRequestWidgetEntity diff --git a/app/serializers/route_entity.rb b/app/serializers/route_entity.rb new file mode 100644 index 00000000000..158fda5e00e --- /dev/null +++ b/app/serializers/route_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class RouteEntity < Grape::Entity + expose :id + expose :source_id + expose :source_type + expose :path +end diff --git a/app/serializers/route_serializer.rb b/app/serializers/route_serializer.rb new file mode 100644 index 00000000000..0b187588301 --- /dev/null +++ b/app/serializers/route_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class RouteSerializer < BaseSerializer + entity RouteEntity +end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index e08b4ac2260..1de2f31f87c 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -49,6 +49,14 @@ module AutoMerge end end + def available_for?(merge_request) + strong_memoize("available_for_#{merge_request.id}") do + merge_request.can_be_merged_by?(current_user) && + merge_request.mergeable_state?(skip_ci_check: true) && + yield + end + end + private def strategy diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index 7c0e9228b28..9ae5bd1b5ec 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -30,7 +30,9 @@ module AutoMerge end def available_for?(merge_request) - merge_request.actual_head_pipeline&.active? + super do + merge_request.actual_head_pipeline&.active? + end end end end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb index eee227be202..c5cbcc7c93b 100644 --- a/app/services/auto_merge_service.rb +++ b/app/services/auto_merge_service.rb @@ -1,23 +1,26 @@ # frozen_string_literal: true class AutoMergeService < BaseService + include Gitlab::Utils::StrongMemoize + STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds' STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze class << self - def all_strategies + def all_strategies_ordered_by_preference STRATEGIES end def get_service_class(strategy) - return unless all_strategies.include?(strategy) + return unless all_strategies_ordered_by_preference.include?(strategy) "::AutoMerge::#{strategy.camelize}Service".constantize end end - def execute(merge_request, strategy) - service = get_service_instance(strategy) + def execute(merge_request, strategy = nil) + strategy ||= preferred_strategy(merge_request) + service = get_service_instance(merge_request, strategy) return :failed unless service&.available_for?(merge_request) @@ -27,37 +30,47 @@ class AutoMergeService < BaseService def update(merge_request) return :failed unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).update(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).update(merge_request) end def process(merge_request) return unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).process(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).process(merge_request) end def cancel(merge_request) return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).cancel(merge_request) end def abort(merge_request, reason) return error("Can't abort the automatic merge", 406) unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).abort(merge_request, reason) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).abort(merge_request, reason) end def available_strategies(merge_request) - self.class.all_strategies.select do |strategy| - get_service_instance(strategy).available_for?(merge_request) + self.class.all_strategies_ordered_by_preference.select do |strategy| + get_service_instance(merge_request, strategy).available_for?(merge_request) end end + def preferred_strategy(merge_request) + available_strategies(merge_request).first + end + private - def get_service_instance(strategy) - self.class.get_service_class(strategy)&.new(project, current_user, params) + def get_service_instance(merge_request, strategy) + strong_memoize("service_instance_#{merge_request.id}_#{strategy}") do + self.class.get_service_class(strategy)&.new(project, current_user, params) + end end end diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index bd4ce693085..86b48b5228d 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -35,6 +35,18 @@ module Clusters application.modsecurity_mode = params[:modsecurity_mode] || 0 end + if application.has_attribute?(:host) + application.host = params[:host] + end + + if application.has_attribute?(:protocol) + application.protocol = params[:protocol] + end + + if application.has_attribute?(:port) + application.port = params[:port] + end + if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end diff --git a/app/services/concerns/deploy_token_methods.rb b/app/services/concerns/deploy_token_methods.rb index c875342a07c..f59a50d6878 100644 --- a/app/services/concerns/deploy_token_methods.rb +++ b/app/services/concerns/deploy_token_methods.rb @@ -14,4 +14,12 @@ module DeployTokenMethods deploy_token.destroy end + + def create_deploy_token_payload_for(deploy_token) + if deploy_token.persisted? + success(deploy_token: deploy_token, http_status: :created) + else + error(deploy_token.errors.full_messages.to_sentence, :bad_request, pass_back: { deploy_token: deploy_token }) + end + end end diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb index a0b43ad3d08..6e671f52d57 100644 --- a/app/services/emails/destroy_service.rb +++ b/app/services/emails/destroy_service.rb @@ -13,7 +13,7 @@ module Emails user.update_secondary_emails! end - result[:status] == 'success' + result[:status] == :success end end end diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index da45bcc7eaa..5c1ee981d0c 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -36,6 +36,8 @@ module Git # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. def enqueue_update_mrs + return if params[:merge_request_branches]&.exclude?(branch_name) + UpdateMergeRequestsWorker.perform_async( project.id, current_user.id, diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index 387cd29d69d..6d1ff97016b 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -42,6 +42,7 @@ module Git push_service_class = push_service_class_for(ref_type) create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit + merge_request_branches = merge_request_branches_for(changes) changes.each do |change| push_service_class.new( @@ -49,6 +50,7 @@ module Git current_user, change: change, push_options: params[:push_options], + merge_request_branches: merge_request_branches, create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project), execute_project_hooks: execute_project_hooks, create_push_event: !create_bulk_push_event @@ -71,5 +73,11 @@ module Git Git::BranchPushService end + + def merge_request_branches_for(changes) + return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true) + + @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute + end end end diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb index 81f761eb61d..aee423659ef 100644 --- a/app/services/groups/deploy_tokens/create_service.rb +++ b/app/services/groups/deploy_tokens/create_service.rb @@ -8,11 +8,7 @@ module Groups def execute deploy_token = create_deploy_token_for(@group, params) - if deploy_token.persisted? - success(deploy_token: deploy_token, http_status: :created) - else - error(deploy_token.errors.full_messages.to_sentence, :bad_request) - end + create_deploy_token_payload_for(deploy_token) end end end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index 548a4a98dc1..f62b9d3c8a6 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -33,10 +33,12 @@ module Groups end def restorer - @restorer ||= Gitlab::ImportExport::Group::TreeRestorer.new(user: @current_user, - shared: @shared, - group: @group, - group_hash: nil) + @restorer ||= Gitlab::ImportExport::Group::LegacyTreeRestorer.new( + user: @current_user, + shared: @shared, + group: @group, + group_hash: nil + ) end def remove_import_file diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 4e7875e0491..fe3ab884302 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -2,15 +2,6 @@ module Groups class TransferService < Groups::BaseService - ERROR_MESSAGES = { - database_not_supported: s_('TransferGroup|Database is not supported.'), - namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), - group_is_already_root: s_('TransferGroup|Group is already a root group.'), - same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), - invalid_policies: s_("TransferGroup|You don't have enough permissions."), - group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.') - }.freeze - TransferError = Class.new(StandardError) attr_reader :error, :new_parent_group @@ -124,7 +115,18 @@ module Groups end def raise_transfer_error(message) - raise TransferError, ERROR_MESSAGES[message] + raise TransferError, localized_error_messages[message] + end + + def localized_error_messages + { + database_not_supported: s_('TransferGroup|Database is not supported.'), + namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), + group_is_already_root: s_('TransferGroup|Group is already a root group.'), + same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), + invalid_policies: s_("TransferGroup|You don't have enough permissions."), + group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.') + }.freeze end end end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb new file mode 100644 index 00000000000..1dcdfb9faea --- /dev/null +++ b/app/services/issues/export_csv_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Issues + class ExportCsvService + include Gitlab::Routing.url_helpers + include GitlabRoutingHelper + + # Target attachment size before base64 encoding + TARGET_FILESIZE = 15000000 + + attr_reader :project + + def initialize(issues_relation, project) + @issues = issues_relation + @labels = @issues.labels_hash + @project = project + end + + def csv_data + csv_builder.render(TARGET_FILESIZE) + end + + def email(user) + Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now + end + + # rubocop: disable CodeReuse/ActiveRecord + def csv_builder + @csv_builder ||= + CsvBuilder.new(@issues.preload(associations_to_preload), header_to_value_hash) + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def associations_to_preload + %i(author assignees timelogs) + end + + def header_to_value_hash + { + 'Issue ID' => 'iid', + 'URL' => -> (issue) { issue_url(issue) }, + 'Title' => 'title', + 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, + 'Description' => 'description', + 'Author' => 'author_name', + 'Author Username' => -> (issue) { issue.author&.username }, + 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, + 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, + 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, + 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, + 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, + 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, + 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) }, + 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, + 'Milestone' => -> (issue) { issue.milestone&.title }, + 'Weight' => -> (issue) { issue.weight }, + 'Labels' => -> (issue) { issue_labels(issue) }, + 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, + 'Time Spent' => -> (issue) { issue_time_spent(issue) } + } + end + + def issue_labels(issue) + @labels[issue.id].sort.join(',').presence + end + + # rubocop: disable CodeReuse/ActiveRecord + def issue_time_spent(issue) + issue.timelogs.map(&:time_spent).sum + end + # rubocop: enable CodeReuse/ActiveRecord + end +end + +Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService') diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index e8d9e6734bd..de4e490281f 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -62,12 +62,12 @@ module JiraImport end def validate - return build_error_response(_('Jira import feature is disabled.')) unless project.jira_issues_import_feature_flag_enabled? - return build_error_response(_('You do not have permissions to run the import.')) unless user.can?(:admin_project, project) - return build_error_response(_('Cannot import because issues are not available in this project.')) unless project.feature_available?(:issues, user) - return build_error_response(_('Jira integration not configured.')) unless project.jira_service&.active? + project.validate_jira_import_settings!(user: user) + return build_error_response(_('Unable to find Jira project to import data from.')) if jira_project_key.blank? return build_error_response(_('Jira import is already running.')) if import_in_progress? + rescue Projects::ImportService::Error => e + build_error_response(e.message) end def build_error_response(message) diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb new file mode 100644 index 00000000000..24341ef1145 --- /dev/null +++ b/app/services/merge_requests/merge_orchestration_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeOrchestrationService < ::BaseService + def execute(merge_request) + return unless can_merge?(merge_request) + + merge_request.update(merge_error: nil) + + if can_merge_automatically?(merge_request) + auto_merge_service.execute(merge_request) + else + merge_request.merge_async(current_user.id, params) + end + end + + def can_merge?(merge_request) + can_merge_automatically?(merge_request) || can_merge_immediately?(merge_request) + end + + def preferred_auto_merge_strategy(merge_request) + auto_merge_service.preferred_strategy(merge_request) + end + + private + + def can_merge_immediately?(merge_request) + merge_request.can_be_merged_by?(current_user) && + merge_request.mergeable_state? + end + + def can_merge_automatically?(merge_request) + auto_merge_service.available_strategies(merge_request).any? + end + + def auto_merge_service + @auto_merge_service ||= AutoMergeService.new(project, current_user, params) + end + end +end diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb new file mode 100644 index 00000000000..afcf0f7678a --- /dev/null +++ b/app/services/merge_requests/pushed_branches_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module MergeRequests + class PushedBranchesService < MergeRequests::BaseService + include ::Gitlab::Utils::StrongMemoize + + # Skip moving this logic into models since it's too specific + # rubocop: disable CodeReuse/ActiveRecord + def execute + return [] if branch_names.blank? + + source_branches = project.source_of_merge_requests.opened + .from_source_branches(branch_names).pluck(:source_branch) + + target_branches = project.merge_requests.opened + .by_target_branch(branch_names).distinct.pluck(:target_branch) + + source_branches.concat(target_branches).to_set + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def branch_names + strong_memoize(:branch_names) do + params[:changes].map do |change| + Gitlab::Git.branch_name(change[:ref]) + end.compact + end + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 1516e33a7c6..2d33e87bf4b 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -79,14 +79,21 @@ module MergeRequests def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) - return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) - merge_request.update(merge_error: nil) - - if merge_request.head_pipeline_active? - AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true) + MergeRequests::MergeOrchestrationService + .new(project, current_user, { sha: last_diff_sha }) + .execute(merge_request) else - merge_request.merge_async(current_user.id, { sha: last_diff_sha }) + return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) + + merge_request.update(merge_error: nil) + + if merge_request.head_pipeline_active? + AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + else + merge_request.merge_async(current_user.id, { sha: last_diff_sha }) + end end end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb index 035707dceb9..ce81f337e47 100644 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ b/app/services/metrics/dashboard/transient_embed_service.rb @@ -30,6 +30,11 @@ module Metrics def sequence [STAGES::EndpointInserter] end + + override :identifiers + def identifiers + Digest::SHA256.hexdigest(params[:embed_json]) + end end end end diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb new file mode 100644 index 00000000000..ff9bb7d6802 --- /dev/null +++ b/app/services/personal_access_tokens/create_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class CreateService < BaseService + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def execute + personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params)) + + if personal_access_token.persisted? + ServiceResponse.success(payload: { personal_access_token: personal_access_token }) + else + ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence) + end + end + + private + + def allowed_params + [ + :name, + :impersonation, + :scopes, + :expires_at + ] + end + end +end diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb index 8cc8fb913a2..2451ab8e0ce 100644 --- a/app/services/pod_logs/base_service.rb +++ b/app/services/pod_logs/base_service.rb @@ -62,13 +62,11 @@ module PodLogs end def get_raw_pods(result) - result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace) - - success(result) + raise NotImplementedError end def get_pod_names(result) - result[:pods] = result[:raw_pods].map(&:metadata).map(&:name) + result[:pods] = result[:raw_pods].map { |p| p[:name] } success(result) end diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb index 0a5185999ab..aac0fa424ca 100644 --- a/app/services/pod_logs/elasticsearch_service.rb +++ b/app/services/pod_logs/elasticsearch_service.rb @@ -23,6 +23,23 @@ module PodLogs super + %i(cursor) end + def get_raw_pods(result) + client = cluster&.application_elastic_stack&.elasticsearch_client + return error(_('Unable to connect to Elasticsearch')) unless client + + result[:raw_pods] = ::Gitlab::Elasticsearch::Logs::Pods.new(client).pods(namespace) + + success(result) + rescue Elasticsearch::Transport::Transport::ServerError => e + ::Gitlab::ErrorTracking.track_exception(e) + + error(_('Elasticsearch returned status code: %{status_code}') % { + # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound" + # there is no method on the exception other than the class name to determine the type of error encountered. + status_code: e.class.name.split('::').last + }) + end + def check_times(result) result[:start_time] = params['start_time'] if params.key?('start_time') && Time.iso8601(params['start_time']) result[:end_time] = params['end_time'] if params.key?('end_time') && Time.iso8601(params['end_time']) @@ -48,7 +65,7 @@ module PodLogs client = cluster&.application_elastic_stack&.elasticsearch_client return error(_('Unable to connect to Elasticsearch')) unless client - response = ::Gitlab::Elasticsearch::Logs.new(client).pod_logs( + response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs( namespace, pod_name: result[:pod_name], container_name: result[:container_name], @@ -69,7 +86,7 @@ module PodLogs # there is no method on the exception other than the class name to determine the type of error encountered. status_code: e.class.name.split('::').last }) - rescue ::Gitlab::Elasticsearch::Logs::InvalidCursor + rescue ::Gitlab::Elasticsearch::Logs::Lines::InvalidCursor error(_('Invalid cursor value provided')) end end diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb index 31e26912c73..0a8072a9037 100644 --- a/app/services/pod_logs/kubernetes_service.rb +++ b/app/services/pod_logs/kubernetes_service.rb @@ -21,6 +21,17 @@ module PodLogs private + def get_raw_pods(result) + result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace).map do |pod| + { + name: pod.metadata.name, + container_names: pod.spec.containers.map(&:name) + } + end + + success(result) + end + def check_pod_name(result) # If pod_name is not received as parameter, get the pod logs of the first # pod of this namespace. @@ -43,11 +54,11 @@ module PodLogs end def check_container_name(result) - pod_details = result[:raw_pods].find { |p| p.metadata.name == result[:pod_name] } - containers = pod_details.spec.containers.map(&:name) + pod_details = result[:raw_pods].find { |p| p[:name] == result[:pod_name] } + container_names = pod_details[:container_names] # select first container if not specified - result[:container_name] ||= containers.first + result[:container_name] ||= container_names.first unless result[:container_name] return error(_('No containers available')) @@ -58,7 +69,7 @@ module PodLogs ' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH })) end - unless containers.include?(result[:container_name]) + unless container_names.include?(result[:container_name]) return error(_('Container does not exist')) end diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb index 2e71650b066..592198ef241 100644 --- a/app/services/projects/deploy_tokens/create_service.rb +++ b/app/services/projects/deploy_tokens/create_service.rb @@ -8,11 +8,7 @@ module Projects def execute deploy_token = create_deploy_token_for(@project, params) - if deploy_token.persisted? - success(deploy_token: deploy_token, http_status: :created) - else - error(deploy_token.errors.full_messages.to_sentence, :bad_request) - end + create_deploy_token_payload_for(deploy_token) end end end diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resources/create_access_token_service.rb new file mode 100644 index 00000000000..fd3c8d78e58 --- /dev/null +++ b/app/services/resources/create_access_token_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Resources + class CreateAccessTokenService < BaseService + attr_accessor :resource_type, :resource + + def initialize(resource_type, resource, user, params = {}) + @resource_type = resource_type + @resource = resource + @current_user = user + @params = params.dup + end + + def execute + return unless feature_enabled? + return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create? + + # We skip authorization by default, since the user creating the bot is not an admin + # and project/group bot users are not created via sign-up + user = create_user + + return error(user.errors.full_messages.to_sentence) unless user.persisted? + return error("Failed to provide maintainer access") unless provision_access(resource, user) + + token_response = create_personal_access_token(user) + + if token_response.success? + success(token_response.payload[:personal_access_token]) + else + error(token_response.message) + end + end + + private + + def feature_enabled? + ::Feature.enabled?(:resource_access_token, resource) + end + + def has_permission_to_create? + case resource_type + when 'project' + can?(current_user, :admin_project, resource) + when 'group' + can?(current_user, :admin_group, resource) + else + false + end + end + + def create_user + Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true) + end + + def default_user_params + { + name: params[:name] || "#{resource.name.to_s.humanize} bot", + email: generate_email, + username: generate_username, + user_type: "#{resource_type}_bot".to_sym + } + end + + def generate_username + base_username = "#{resource_type}_#{resource.id}_bot" + + uniquify.string(base_username) { |s| User.find_by_username(s) } + end + + def generate_email + email_pattern = "#{resource_type}#{resource.id}_bot%s@example.com" + + uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s| + User.find_by_email(s) + end + end + + def uniquify + Uniquify.new + end + + def create_personal_access_token(user) + PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute + end + + def personal_access_token_params + { + name: "#{resource_type}_bot", + impersonation: false, + scopes: params[:scopes] || default_scopes, + expires_at: params[:expires_at] || nil + } + end + + def default_scopes + Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user] + end + + def provision_access(resource, user) + resource.add_maintainer(user) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def success(access_token) + ServiceResponse.success(payload: { access_token: access_token }) + end + end +end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 0b74bd77e28..155013db344 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -38,9 +38,7 @@ module Snippets private def save_and_commit - snippet_saved = @snippet.with_transaction_returning_status do - @snippet.save && @snippet.store_mentions! - end + snippet_saved = @snippet.save if snippet_saved && Feature.enabled?(:version_snippets, current_user) create_repository diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb new file mode 100644 index 00000000000..5bb6f6a1dee --- /dev/null +++ b/app/services/terraform/remote_state_handler.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Terraform + class RemoteStateHandler < BaseService + include Gitlab::OptimisticLocking + + StateLockedError = Class.new(StandardError) + + # rubocop: disable CodeReuse/ActiveRecord + def find_with_lock + raise ArgumentError unless params[:name].present? + + state = Terraform::State.find_by(project: project, name: params[:name]) + raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state + + retry_optimistic_lock(state) { |state| yield state } if state && block_given? + state + end + # rubocop: enable CodeReuse/ActiveRecord + + def create_or_find! + raise ArgumentError unless params[:name].present? + + Terraform::State.create_or_find_by(project: project, name: params[:name]) + end + + def handle_with_lock + retrieve_with_lock do |state| + raise StateLockedError unless lock_matches?(state) + + yield state if block_given? + + state.save! unless state.destroyed? + end + end + + def lock! + raise ArgumentError if params[:lock_id].blank? + + retrieve_with_lock do |state| + raise StateLockedError if state.locked? + + state.lock_xid = params[:lock_id] + state.locked_by_user = current_user + state.locked_at = Time.now + + state.save! + end + end + + def unlock! + retrieve_with_lock do |state| + # force-unlock does not pass ID, so we ignore it if it is missing + raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state) + + state.lock_xid = nil + state.locked_by_user = nil + state.locked_at = nil + + state.save! + end + end + + private + + def retrieve_with_lock + create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } } + end + + def lock_matches?(state) + return true if state.lock_xid.nil? && params[:lock_id].nil? + + ActiveSupport::SecurityUtils + .secure_compare(state.lock_xid.to_s, params[:lock_id].to_s) + end + end +end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 6f9f307c322..3938d675596 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -81,7 +81,8 @@ module Users :private_profile, :organization, :location, - :public_email + :public_email, + :user_type ] end @@ -95,7 +96,8 @@ module Users :first_name, :last_name, :password, - :username + :username, + :user_type ] end @@ -127,6 +129,8 @@ module Users user_params[:external] = user_external? end + user_params.delete(:user_type) unless project_bot?(user_params[:user_type]) + user_params end @@ -137,6 +141,10 @@ module Users def user_external? user_default_internal_regex_instance.match(params[:email]).nil? end + + def project_bot?(user_type) + user_type&.to_sym == :project_bot + end end end diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 427314a87bb..967fcdc704e 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -56,31 +56,10 @@ module RecordsUploads size: file.size, path: upload_path, model: model, - mount_point: mounted_as, - store: initial_store + mount_point: mounted_as ) end - def initial_store - if immediately_remote_stored? - ::ObjectStorage::Store::REMOTE - else - ::ObjectStorage::Store::LOCAL - end - end - - def immediately_remote_stored? - object_storage_available? && direct_upload_enabled? - end - - def object_storage_available? - self.class.ancestors.include?(ObjectStorage::Concern) - end - - def direct_upload_enabled? - self.class.object_store_enabled? && self.class.direct_upload_enabled? - end - # Before removing an attachment, destroy any Upload records at the same path # # Called `before :remove` diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index 9c5ae8a8bdc..2306313fc82 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -12,7 +12,7 @@ module Terraform encrypt(key: :key) def filename - "#{model.id}.tfstate" + "#{model.uuid}.tfstate" end def store_dir diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index f860b7a61a2..0120d4038b9 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -28,7 +28,7 @@ %hr .append-bottom-20 - = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner) + = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com? .row .col-md-6 diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 3fa957f38a0..4d8df4cc12a 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -5,7 +5,7 @@ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') } = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } -- if Feature.enabled?(:new_variables_ui, @project || @group) +- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true) - is_group = !@group.nil? #js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex} } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 06e3bca99a1..80a14412968 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -5,7 +5,8 @@ .mobile-overlay .alert-wrapper = render 'shared/outdated_browser' - = render_if_exists "layouts/header/ee_license_banner" + - if Feature.enabled?(:subscribable_banner_license) + = render_if_exists "layouts/header/ee_subscribable_banner" = render "layouts/broadcast" = render "layouts/header/read_only_banner" = render "layouts/nav/classification_level_banner" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index c6299f244ec..410b120396d 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -26,7 +26,7 @@ - if current_user_menu?(:settings) %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } - = render_if_exists 'layouts/header/buy_ci_minutes' + = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group - if current_user_menu?(:help) %li.divider.d-md-none diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml new file mode 100644 index 00000000000..b777ca1e57d --- /dev/null +++ b/app/views/notify/issues_csv_email.html.haml @@ -0,0 +1,9 @@ +-# haml-lint:disable NoPlainNodes +%p{ style: 'font-size:18px; text-align:center; line-height:30px;' } + Your CSV export of #{ pluralize(@written_count, 'issue') } from project + %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" } + = @project.full_name + has been added to this email as an attachment. + - if @truncated + %p + This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues. diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb new file mode 100644 index 00000000000..5d4128e3ae9 --- /dev/null +++ b/app/views/notify/issues_csv_email.text.erb @@ -0,0 +1,5 @@ +Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment. + +<% if @truncated %> +This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues. +<% end %> diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index f9222387e97..8217608db4e 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -8,4 +8,6 @@ - unless project.empty_repo? = render 'shared/auto_devops_implicitly_enabled_banner', project: project = render_if_exists 'projects/above_size_limit_warning', project: project + - if Feature.enabled?(:subscribable_banner_subscription) + = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index d9887cb470a..be58ecb3572 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -14,6 +14,7 @@ = @project.name %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project .home-panel-metadata.d-flex.flex-wrap.text-secondary - if can?(current_user, :read_project, @project) %span.text-secondary diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index ec05ff50f25..2e5953bf0a6 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -4,11 +4,12 @@ - commits = @commits - hidden = @hidden_commit_count +- commits_count = @commits.size - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| %li.commit-header.js-commit-header{ data: { day: day } } %span.day= l(day, format: '%d %b, %Y') - %span.commits-count= n_("%d commit", "%d commits", commits.count) % commits.count + %span.commits-count= n_("%d commit", "%d commits", commits_count) % commits_count %li.commits-row{ data: { day: day } } %ul.content-list.commit-list.flex-list @@ -17,3 +18,9 @@ - if hidden > 0 %li.alert.alert-warning = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) + +- if commits_count == 0 + .mt-4.text-center + .bold + = _('Your search didn\'t match any commits.') + = _('Try changing or removing filters.') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b0d9dfb0d37..da20fee227a 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -10,27 +10,25 @@ .card .card-header {{ __('Recent Project Activity') }} - .content-block - .container-fluid - .row - .col-12.column{ "v-for" => "item in state.summary", ":class" => "summaryTableColumnClass" } - %h3.header {{ item.value }} - %p.text {{ item.title }} - .col-12.column{ ":class" => "summaryTableColumnClass" } - .dropdown.inline.js-ca-dropdown - %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} - %i.fa.fa-chevron-down - %ul.dropdown-menu.dropdown-menu-right - %li - %a{ "href" => "#", "data-value" => "7" } - {{ n__('Last %d day', 'Last %d days', 7) }} - %li - %a{ "href" => "#", "data-value" => "30" } - {{ n__('Last %d day', 'Last %d days', 30) }} - %li - %a{ "href" => "#", "data-value" => "90" } - {{ n__('Last %d day', 'Last %d days', 90) }} + .d-flex.justify-content-between + .flex-grow.text-center{ "v-for" => "item in state.summary" } + %h3.header {{ item.value }} + %p.text {{ item.title }} + .flex-grow.align-self-center.text-center + .dropdown.inline.js-ca-dropdown + %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} + %i.fa.fa-chevron-down + %ul.dropdown-menu.dropdown-menu-right + %li + %a{ "href" => "#", "data-value" => "7" } + {{ n__('Last %d day', 'Last %d days', 7) }} + %li + %a{ "href" => "#", "data-value" => "30" } + {{ n__('Last %d day', 'Last %d days', 30) }} + %li + %a{ "href" => "#", "data-value" => "90" } + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .card.stage-panel .card-header.border-bottom-0 diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml index 6003f33f0ba..4106bcc2e5a 100644 --- a/app/views/projects/import/jira/show.html.haml +++ b/app/views/projects/import/jira/show.html.haml @@ -1,6 +1,9 @@ -- if Feature.enabled?(:jira_issue_import_vue, @project) +- if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) .js-jira-import-root{ data: { project_path: @project.full_path, - is_jira_configured: @is_jira_configured.to_s, + issues_path: project_issues_path(@project), + is_jira_configured: @project.jira_service.present?.to_s, + jira_projects: @jira_projects.to_json, + in_progress_illustration: image_path('illustrations/export-import.svg'), setup_illustration: image_path('illustrations/manual_action.svg') } } - else - title = _('Jira Issue Import') diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index c347b8d2c9c..71c9bb36936 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -8,7 +8,7 @@ .btn-group - if show_export_button - = render_if_exists 'projects/issues/export_csv/button' + = render 'projects/issues/export_csv/button' - if show_import_button = render 'projects/issues/import_csv/button' @@ -23,7 +23,7 @@ id: "new_issue_link" - if show_export_button - = render_if_exists 'projects/issues/export_csv/modal' + = render 'projects/issues/export_csv/modal' - if show_import_button = render 'projects/issues/import_csv/modal' diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/projects/issues/export_csv/_button.html.haml new file mode 100644 index 00000000000..ef3fb438641 --- /dev/null +++ b/app/views/projects/issues/export_csv/_button.html.haml @@ -0,0 +1,4 @@ +- if current_user + %button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'), + data: { toggle: 'modal', target: '.issues-export-modal', qa_selector: 'export_as_csv_button' } } + = sprite_icon('export') diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml new file mode 100644 index 00000000000..af3a087ca59 --- /dev/null +++ b/app/views/projects/issues/export_csv/_modal.html.haml @@ -0,0 +1,22 @@ +-# haml-lint:disable NoPlainNodes +- if current_user + .issues-export-modal.modal + .modal-dialog + .modal-content{ data: { qa_selector: 'export_issues_modal' } } + .modal-header + %h3 + = _('Export issues') + .svg-content.import-export-svg-container + = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' + %a.close{ href: '#', 'data-dismiss' => 'modal' } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .modal-body + .modal-subheader + = icon('check', { class: 'checkmark' }) + %strong.prepend-left-10 + - issues_count = issuables_count_for_state(:issues, params[:state]) + = n_('%d issue selected', '%d issues selected', issues_count) % issues_count + .modal-text + = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email } + .modal-footer + = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" } diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index bd9defe5f74..0dbd6a48ec5 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -12,11 +12,10 @@ .col-lg-9 = form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| = render 'shared/service_settings', form: form, service: @service - - if @service.editable? - .footer-block.row-content-block - = service_save_button(@service) - - = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel' + .footer-block.row-content-block + = service_save_button(@service) + + = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel' - if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true) %hr diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml index 35d655e4b32..1b5b794a7aa 100644 --- a/app/views/projects/services/prometheus/_help.html.haml +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -3,7 +3,5 @@ %h4.append-bottom-default = s_('PrometheusService|Manual configuration') - -- unless @service.editable? - .info-well - = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') +%p + = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.') diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml index 3d7a6b021a8..b0fa750e131 100644 --- a/app/views/projects/settings/operations/_prometheus.html.haml +++ b/app/views/projects/settings/operations/_prometheus.html.haml @@ -13,7 +13,5 @@ %b.append-bottom-default = s_('PrometheusService|Manual configuration') - - - unless service.editable? - .info-well - = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') + %p + = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.') diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index d29ba3eedc6..3d61943193f 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -54,6 +54,10 @@ .metadata-info.prepend-top-8 %span.user-access-role.d-block= Gitlab::Access.human_access(access) + - if !explore_projects_tab? + .metadata-info.prepend-top-8 + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project + - if show_last_commit_as_description .description.d-none.d-sm-block.append-right-default = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index 24b4eae0c58..675a8f922c4 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -47,5 +47,16 @@ .col-sm-10 = f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control' .form-text.text-muted= _('You can set up jobs to only use Runners with specific tags. Separate tags with commas.') + - if local_assigns[:in_gitlab_com_admin_context] + .form-group.row + = label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do + = _('Public projects Minutes cost factor') + .col-sm-10 + = f.text_field :public_projects_minutes_cost_factor, class: 'form-control' + .form-group.row + = label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do + = _('Private projects Minutes cost factor') + .col-sm-10 + = f.text_field :private_projects_minutes_cost_factor, class: 'form-control' .form-actions = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 5ba6d52fefe..396b6e56ea9 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,54 +1,58 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') - -.snippet-form-holder - = form_for @snippet, url: url, - html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, - data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f| - = form_errors(@snippet) - - .form-group - = f.label :title, class: 'label-bold' - = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true - - .form-group.js-description-input - - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') - - is_expanded = @snippet.description && !@snippet.description.empty? - = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' - .js-collapsible-input - .js-collapsed{ class: ('d-none' if is_expanded) } - = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } - .js-expanded{ class: ('d-none' if !is_expanded) } - = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' - = render 'shared/notes/hints' - - .form-group.file-editor - = f.label :file_name, s_('Snippets|File') - .file-holder.snippet - .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name' - .file-content.code - %pre#editor{ data: { 'editor-loading': true } }= @snippet.content - = f.hidden_field :content, class: 'snippet-file-content' - - .form-group - .font-weight-bold - = _('Visibility level') - = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' - = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false - - - if params[:files] - - params[:files].each_with_index do |file, index| - = hidden_field_tag "files[]", file, id: "files_#{index}" - - .form-actions - - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" - - else - = f.submit 'Save changes', class: "btn-success btn" - - - if @snippet.project_id - = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" - - else - = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" +- if Feature.disabled?(:monaco_snippets) + - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + +- if Feature.enabled?(:snippets_edit_vue) + #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } } +- else + .snippet-form-holder + = form_for @snippet, url: url, + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, + data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f| + = form_errors(@snippet) + + .form-group + = f.label :title, class: 'label-bold' + = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true + + .form-group.js-description-input + - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') + - is_expanded = @snippet.description && !@snippet.description.empty? + = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' + .js-collapsible-input + .js-collapsed{ class: ('d-none' if is_expanded) } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + .js-expanded{ class: ('d-none' if !is_expanded) } + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' + = render 'shared/notes/hints' + + .form-group.file-editor + = f.label :file_name, s_('Snippets|File') + .file-holder.snippet + .js-file-title.file-title-flex-parent + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name' + .file-content.code + %pre#editor{ data: { 'editor-loading': true } }= @snippet.content + = f.hidden_field :content, class: 'snippet-file-content' + + .form-group + .font-weight-bold + = _('Visibility level') + = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' + = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false + + - if params[:files] + - params[:files].each_with_index do |file, index| + = hidden_field_tag "files[]", file, id: "files_#{index}" + + .form-actions + - if @snippet.new_record? + = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" + - else + = f.submit 'Save changes', class: "btn-success btn" + + - if @snippet.project_id + = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" + - else + = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 38f518458d6..57d41bfaec2 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -709,7 +709,7 @@ :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: true - :name: pipeline_creation:create_pipeline :feature_category: :continuous_integration :has_external_dependencies: @@ -1046,6 +1046,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: +- :name: export_csv + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: - :name: file_hook :feature_category: :integrations :has_external_dependencies: diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb index 25ee4539cab..955387b5ad4 100644 --- a/app/workers/concerns/cronjob_queue.rb +++ b/app/workers/concerns/cronjob_queue.rb @@ -10,4 +10,14 @@ module CronjobQueue sidekiq_options retry: false worker_context project: nil, namespace: nil, user: nil end + + class_methods do + # Cronjobs never get scheduled with arguments, so this is safe to + # override + def context_for_arguments(_args) + return if Gitlab::ApplicationContext.current_context_include?('meta.caller_id') + + Gitlab::ApplicationContext.new(caller_id: "Cronjob") + end + end end diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index 3da21c56eff..9cbc75f8944 100644 --- a/app/workers/create_commit_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -21,14 +21,19 @@ class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker return if commits.empty? - # This calculates and caches the signature in the database - commits.each do |commit| + # Instantiate commits first to lazily load the signatures + commits.map! do |commit| case commit.signature_type when :PGP - Gitlab::Gpg::Commit.new(commit).signature + Gitlab::Gpg::Commit.new(commit) when :X509 - Gitlab::X509::Commit.new(commit).signature + Gitlab::X509::Commit.new(commit) end + end + + # This calculates and caches the signature in the database + commits.each do |commit| + commit&.signature rescue => e Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger end diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 1d2708cdb44..0710ef9298b 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker +class ExpirePipelineCacheWorker include ApplicationWorker include PipelineQueue @@ -8,6 +8,8 @@ class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker urgency :high worker_resource_boundary :cpu + idempotent! + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) pipeline = Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb new file mode 100644 index 00000000000..9e2b3ad9bb4 --- /dev/null +++ b/app/workers/export_csv_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :issue_tracking + worker_resource_boundary :cpu + + def perform(current_user_id, project_id, params) + @current_user = User.find(current_user_id) + @project = Project.find(project_id) + + params.symbolize_keys! + params[:project_id] = project_id + params.delete(:sort) + + issues = IssuesFinder.new(@current_user, params).execute + + Issues::ExportCsvService.new(issues, @project).email(@current_user) + end +end diff --git a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb index 1d57b77ac7e..3e2cfe56cea 100644 --- a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb @@ -10,7 +10,7 @@ module Gitlab def import(project) JiraImport.cache_cleanup(project.id) - project.latest_jira_import&.finish! + project.latest_jira_import.finish! end end end diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb index c60bee0ffdc..2166655115d 100644 --- a/app/workers/project_daily_statistics_worker.rb +++ b/app/workers/project_daily_statistics_worker.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/214585 class ProjectDailyStatisticsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker |