diff options
101 files changed, 1531 insertions, 603 deletions
@@ -290,7 +290,7 @@ gem 'gitlab_chronic_duration', '~> 0.10.6.2' gem 'rack-proxy', '~> 0.6.0' gem 'sassc-rails', '~> 2.1.0' -gem 'terser', '~> 1.0' +gem 'gitlab-terser', '1.0.1.1' gem 'addressable', '~> 2.7' gem 'font-awesome-rails', '~> 4.7' diff --git a/Gemfile.lock b/Gemfile.lock index 97aa43db2a2..8699ec0a21d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -452,6 +452,8 @@ GEM rubocop-performance (~> 1.5.2) rubocop-rails (~> 2.5) rubocop-rspec (~> 1.36) + gitlab-terser (1.0.1.1) + execjs (>= 0.3.0, < 3) gitlab_chronic_duration (0.10.6.2) numerizer (~> 0.2) gitlab_omniauth-ldap (2.1.1) @@ -1130,8 +1132,6 @@ GEM temple (0.8.2) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - terser (1.0.1) - execjs (>= 0.3.0, < 3) test-prof (0.12.0) text (1.3.1) thin (1.7.2) @@ -1333,6 +1333,7 @@ DEPENDENCIES gitlab-puma_worker_killer (~> 0.1.1.gitlab.1) gitlab-sidekiq-fetcher (= 0.5.2) gitlab-styles (~> 4.3.0) + gitlab-terser (= 1.0.1.1) gitlab_chronic_duration (~> 0.10.6.2) gitlab_omniauth-ldap (~> 2.1.1) gon (~> 6.2) @@ -1482,7 +1483,6 @@ DEPENDENCIES stackprof (~> 0.2.15) state_machines-activerecord (~> 0.6.0) sys-filesystem (~> 1.1.6) - terser (~> 1.0) test-prof (~> 0.12.0) thin (~> 1.7.0) timecop (~> 0.9.1) diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index bc69c02e21e..7055cd42978 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -572,7 +572,7 @@ export class AwardsHandler { } findMatchingEmojiElements(query) { - const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name); + const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $matchingElements = $emojiElements.filter( (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue index 53e004b4fc0..f0dafa7ef53 100644 --- a/app/assets/javascripts/clusters/forms/components/integration_form.vue +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -24,10 +24,10 @@ export default { }, inject: { autoDevopsHelpPath: { - type: String, + default: '', }, externalEndpointHelpPath: { - type: String, + default: '', }, }, data() { diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 1bdc7b3a8b5..b03da311c43 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,4 +1,3 @@ -import { uniq } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import emojiAliases from 'emojis/aliases.json'; import axios from '../lib/utils/axios_utils'; @@ -67,49 +66,111 @@ export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } +export function getValidEmojiUnicodeValues() { + return Object.values(emojiMap).map(({ e }) => e); +} + +export function getValidEmojiDescriptions() { + return Object.values(emojiMap).map(({ d }) => d); +} + /** - * Search emoji by name or alias. Returns a normalized, deduplicated list of - * names. + * Retrieves an emoji by name or alias. * - * Calling with an empty filter returns an empty array. + * Note: `initEmojiMap` must have been called and completed before this method + * can safely be called. * - * @param {String} - * @returns {Array} + * @param {String} query The emoji name + * @param {Boolean} fallback If true, a fallback emoji will be returned if the + * named emoji does not exist. Defaults to false. + * @returns {Object} The matching emoji. */ -export function queryEmojiNames(filter) { - const matches = fuzzaldrinPlus.filter(validEmojiNames, filter); - return uniq(matches.map(name => normalizeEmojiName(name))); +export function getEmoji(query, fallback = false) { + if (!emojiMap) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + const lowercaseQuery = query.toLowerCase(); + const name = normalizeEmojiName(lowercaseQuery); + + if (name in emojiMap) { + return emojiMap[name]; + } + + if (fallback) { + return emojiMap.grey_question; + } + + return null; } +const searchMatchers = { + fuzzy: (value, query) => fuzzaldrinPlus.score(value, query) > 0, // Fuzzy matching compares using a fuzzy matching library + contains: (value, query) => value.indexOf(query.toLowerCase()) >= 0, // Contains matching compares by indexOf + exact: (value, query) => value === query.toLowerCase(), // Exact matching compares by equality +}; + +const searchPredicates = { + name: (matcher, query) => emoji => matcher(emoji.name, query), // Search by name + alias: (matcher, query) => emoji => emoji.aliases.some(v => matcher(v, query)), // Search by alias + description: (matcher, query) => emoji => matcher(emoji.d, query), // Search by description + unicode: (matcher, query) => emoji => emoji.e === query, // Search by unicode value (always exact) +}; + /** - * Searches emoji by name, alias, description, and unicode value and returns an - * array of matches. + * Searches emoji by name, aliases, description, and unicode value and returns + * an array of matches. + * + * Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy + * and the query is empty. * * Note: `initEmojiMap` must have been called and completed before this method * can safely be called. * - * @param {String} query The search query - * @returns {Object[]} A list of emoji that match the query + * @param {String} query Search query. + * @param {Object} opts Search options (optional). + * @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias', + * 'description', and 'unicode' (value). Default is all (four) fields. + * @param {String} opts.match Search method to use. Choices are 'exact', + * 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the + * default) compares by equality. Contains matching compares by indexOf. Fuzzy + * matching compares using a fuzzy matching library. + * @param {Boolean} opts.fallback If true, a fallback emoji will be returned if + * the result set is empty. Defaults to false. + * @returns {Object[]} A list of emoji that match the query. */ -export function searchEmoji(query) { - if (!emojiMap) +export function searchEmoji(query, opts) { + if (!emojiMap) { // eslint-disable-next-line @gitlab/require-i18n-strings throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + const { + fields = ['name', 'alias', 'description', 'unicode'], + match = 'exact', + fallback = false, + } = opts || {}; - const matches = s => fuzzaldrinPlus.score(s, query) > 0; - - // Search emoji - return Object.values(emojiMap).filter( - emoji => - // by name - matches(emoji.name) || - // by alias - emoji.aliases.some(matches) || - // by description - matches(emoji.d) || - // by unicode value - query === emoji.e, + // optimization for an exact match in name and alias + if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) { + const emoji = getEmoji(query, fallback); + return emoji ? [emoji] : []; + } + + const matcher = searchMatchers[match] || searchMatchers.exact; + const predicates = fields.map(f => searchPredicates[f](matcher, query)); + + const results = Object.values(emojiMap).filter(emoji => + predicates.some(predicate => predicate(emoji)), ); + + // Fallback to question mark for unknown emojis + if (fallback && results.length === 0) { + return [emojiMap.grey_question]; + } + + return results; } let emojiCategoryMap; @@ -136,16 +197,10 @@ export function getEmojiCategoryMap() { } export function getEmojiInfo(query) { - let name = normalizeEmojiName(query); - let emojiInfo = emojiMap[name]; - - // Fallback to question mark for unknown emojis - if (!emojiInfo) { - name = 'grey_question'; - emojiInfo = emojiMap[name]; - } - - return { ...emojiInfo, name }; + return searchEmoji(query, { + fields: ['name', 'alias'], + fallback: true, + })[0]; } export function emojiFallbackImageSrc(inputName) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 0329006c62a..5b604cc2a05 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -191,8 +191,7 @@ class GfmAutoComplete { } return tmpl; }, - // eslint-disable-next-line no-template-curly-in-string - insertTpl: ':${name}:', + insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction, skipSpecialCharacterTest: true, data: GfmAutoComplete.defaultLoadingData, callbacks: { @@ -612,12 +611,7 @@ class GfmAutoComplete { } else if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { - Emoji.initEmojiMap() - .then(() => { - this.loadData($input, at, Emoji.getValidEmojiNames()); - GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; - }) - .catch(() => {}); + this.loadEmojiData($input, at).catch(() => {}); } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then(data => { @@ -640,6 +634,18 @@ class GfmAutoComplete { return $input.trigger('keyup'); } + async loadEmojiData($input, at) { + await Emoji.initEmojiMap(); + + this.loadData($input, at, [ + ...Emoji.getValidEmojiNames(), + ...Emoji.getValidEmojiDescriptions(), + ...Emoji.getValidEmojiUnicodeValues(), + ]); + + GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; + } + clearCache() { this.cachedData = {}; } @@ -708,12 +714,16 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; // Emoji GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.Emoji = { + insertTemplateFunction(value) { + const { name = value.name } = Emoji.searchEmoji(value.name, { match: 'contains' })[0] || {}; + return `:${name}:`; + }, templateFunction(name) { // glEmojiTag helper is loaded on-demand in fetchData() - if (GfmAutoComplete.glEmojiTag) { - return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`; - } - return `<li>${name}</li>`; + if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`; + + const emoji = Emoji.searchEmoji(name, { match: 'contains' })[0]; + return `<li>${name} ${GfmAutoComplete.glEmojiTag(emoji?.name || name)}</li>`; }, }; // Team Members diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 993d51370ec..1a4ecc12f01 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,4 +1,5 @@ export const BYTES_IN_KIB = 1024; +export const BYTES_IN_KB = 1000; export const HIDDEN_CLASS = 'hidden'; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index bc87232f40b..2424d6cbf3b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,4 +1,4 @@ -import { BYTES_IN_KIB } from './constants'; +import { BYTES_IN_KIB, BYTES_IN_KB } from './constants'; import { sprintf, __ } from '~/locale'; /** @@ -35,6 +35,18 @@ export function formatRelevantDigits(number) { } /** + * Utility function that calculates KB of the given bytes. + * Note: This method calculates KiloBytes as opposed to + * Kibibytes. For Kibibytes, bytesToKiB should be used. + * + * @param {Number} number bytes + * @return {Number} KiB + */ +export function bytesToKB(number) { + return number / BYTES_IN_KB; +} + +/** * Utility function that calculates KiB of the given bytes. * * @param {Number} number bytes diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index 533065b2d4d..dfce1cb75d3 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -70,14 +70,18 @@ export default { </script> <template> - <div :data-for="name" class="project-feature-controls"> + <div + :data-for="name" + class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0" + > <input v-if="name" :name="name" :value="value" type="hidden" /> <project-feature-toggle + class="gl-flex-grow-0 gl-mr-3" :value="featureEnabled" :disabled-input="disabledInput" @change="toggleFeature" /> - <div class="select-wrapper"> + <div class="select-wrapper gl-flex-fill-1"> <select :disabled="displaySelectInput" class="form-control project-repo-select select-control" 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 a95f0af46cd..e19afe67789 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 @@ -292,14 +292,16 @@ export default { <template> <div> - <div class="project-visibility-setting"> + <div + class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5" + > <project-setting-row ref="project-visibility-settings" :help-path="visibilityHelpPath" :label="s__('ProjectSettings|Project visibility')" > - <div class="project-feature-controls"> - <div class="select-wrapper"> + <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> + <div class="select-wrapper gl-flex-fill-1"> <select v-model="visibilityLevel" :disabled="!canChangeVisibilityLevel" @@ -327,7 +329,7 @@ export default { </div> </div> <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> - <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="request-access"> + <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="gl-line-height-28"> <input :value="requestAccessEnabled" type="hidden" @@ -338,7 +340,10 @@ export default { </label> </project-setting-row> </div> - <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> + <div + :class="{ 'highlight-changes': highlightChangesClass }" + class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10" + > <project-setting-row ref="issues-settings" :label="s__('ProjectSettings|Issues')" @@ -361,7 +366,7 @@ export default { name="project[project_feature_attributes][repository_access_level]" /> </project-setting-row> - <div class="project-feature-setting-group"> + <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> <project-setting-row ref="merge-request-settings" :label="s__('ProjectSettings|Merge requests')" @@ -516,8 +521,8 @@ export default { ) " > - <div class="project-feature-controls"> - <div class="select-wrapper"> + <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> + <div class="select-wrapper gl-flex-fill-1"> <select v-model="metricsDashboardAccessLevel" :disabled="metricsOptionsDropdownEnabled" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 9f7fe85fb0d..7aee2573ce1 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -126,7 +126,7 @@ export default { }; </script> <template> - <div class="ci-job-component"> + <div class="ci-job-component" data-qa-selector="job_item_container"> <gl-link v-if="status.has_details" v-gl-tooltip="{ boundary, placement: 'bottom' }" @@ -156,6 +156,7 @@ export default { :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" + data-qa-selector="action_button" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index bc2319c0f36..9d72bf4394e 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,7 +1,6 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', @@ -9,7 +8,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { showComparisonState: { @@ -97,14 +96,7 @@ export default { </script> <template> - <div - v-tooltip - :title="tooltipText" - class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" - > + <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon"> <gl-icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index 29d4516bece..861661d3519 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -59,7 +59,7 @@ export default { </script> <template> - <label class="toggle-wrapper"> + <label class="gl-mt-2"> <input v-if="name" :name="name" :value="value" type="hidden" /> <button type="button" diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index eb602651d43..d3ab4be925b 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -8,7 +8,6 @@ @import './pages/commits'; @import './pages/deploy_keys'; @import './pages/detail_page'; -@import './pages/diff'; @import './pages/editor'; @import './pages/environment_logs'; @import './pages/error_list'; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index f875420b9c9..e40b95cdce6 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -70,3 +70,4 @@ @import 'framework/spinner'; @import 'framework/card'; @import 'framework/editor-lite'; +@import 'framework/diffs'; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/framework/diffs.scss index 7d622dda070..c0a2350d080 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -267,6 +267,7 @@ } } } + //.view.swipe .view.onion-skin { .onion-skin-frame { @@ -335,6 +336,7 @@ } } } + //.view.onion-skin } @@ -961,15 +963,13 @@ table.code { .frame.click-to-comment, .btn-transparent.image-diff-overlay-add-comment { position: relative; - cursor: image-url('illustrations/image_comment_light_cursor.svg') - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; // Retina cursor // scss-lint:disable DuplicateProperty cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, - image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; .comment-indicator { @@ -1078,85 +1078,6 @@ table.code { position: relative; } -.diff-tree-list { - position: -webkit-sticky; - position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px; - top: $top-pos; - max-height: calc(100vh - #{$top-pos}); - z-index: 202; - - .with-system-header & { - top: $top-pos + $system-header-height; - } - - .with-system-header.with-performance-bar & { - top: $top-pos + $system-header-height + $performance-bar-height; - } - - .with-performance-bar & { - $performance-bar-top-pos: $performance-bar-height + $top-pos; - top: $performance-bar-top-pos; - max-height: calc(100vh - #{$performance-bar-top-pos}); - } - - .drag-handle { - bottom: 16px; - transform: translateX(10px); - } -} - -.diff-files-holder { - flex: 1; - min-width: 0; - z-index: 201; -} - -.compare-versions-container { - min-width: 0; -} - -.tree-list-holder { - height: 100%; - - .file-row { - margin-left: 0; - margin-right: 0; - } -} - -.tree-list-scroll { - max-height: 100%; - padding-bottom: $grid-size; - overflow-y: scroll; - overflow-x: auto; -} - -.tree-list-search { - flex: 0 0 34px; - - .form-control { - padding-left: 30px; - } -} - -.tree-list-icon { - top: 50%; - left: 10px; - transform: translateY(-50%); - - &, - svg { - fill: $gl-text-color-tertiary; - } -} - -.tree-list-clear-icon { - right: 10px; - left: auto; - line-height: 0; -} - .discussion-collapsible { margin: 0 $gl-padding $gl-padding 71px; @@ -1172,30 +1093,6 @@ table.code { } } -@media (max-width: map-get($grid-breakpoints, md)-1) { - .diffs .files { - @include fixed-width-container; - flex-direction: column; - - .diff-tree-list { - position: relative; - top: 0; - // !important is required to override inline styles of resizable sidebar - width: 100% !important; - } - - .tree-list-holder { - max-height: calc(50px + 50vh); - padding-right: 0; - } - } - - .discussion-collapsible { - margin: $gl-padding; - margin-top: 0; - } -} - .image-diff-overlay, .image-diff-overlay-add-comment { top: 0; @@ -1218,3 +1115,15 @@ table.code { display: none; } } + +@media (max-width: map-get($grid-breakpoints, md)-1) { + .diffs .files { + @include fixed-width-container; + flex-direction: column; + } + + .discussion-collapsible { + margin: $gl-padding; + margin-top: 0; + } +} diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss new file mode 100644 index 00000000000..5553dffac05 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -0,0 +1,96 @@ +@import 'mixins_and_variables_and_functions'; + +.compare-versions-container { + min-width: 0; +} + +.diff-files-holder { + flex: 1; + min-width: 0; + z-index: 201; +} + +.diff-tree-list { + position: -webkit-sticky; + position: sticky; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px; + top: $top-pos; + max-height: calc(100vh - #{$top-pos}); + z-index: 202; + + .with-system-header & { + top: $top-pos + $system-header-height; + } + + .with-system-header.with-performance-bar & { + top: $top-pos + $system-header-height + $performance-bar-height; + } + + .with-performance-bar & { + $performance-bar-top-pos: $performance-bar-height + $top-pos; + top: $performance-bar-top-pos; + max-height: calc(100vh - #{$performance-bar-top-pos}); + } + + .drag-handle { + bottom: 16px; + transform: translateX(10px); + } +} + +.tree-list-holder { + height: 100%; + + .file-row { + margin-left: 0; + margin-right: 0; + } +} + +.tree-list-scroll { + max-height: 100%; + padding-bottom: $grid-size; + overflow-y: scroll; + overflow-x: auto; +} + +.tree-list-search { + flex: 0 0 34px; + + .form-control { + padding-left: 30px; + } +} + +.tree-list-icon { + top: 50%; + left: 10px; + transform: translateY(-50%); + + &, + svg { + fill: var(--gray-400, $gray-400); + } +} + +.tree-list-clear-icon { + right: 10px; + left: auto; + line-height: 0; +} + +@media (max-width: map-get($grid-breakpoints, md)-1) { + .diffs .files { + .diff-tree-list { + position: relative; + top: 0; + // !important is required to override inline styles of resizable sidebar + width: 100% !important; + } + + .tree-list-holder { + max-height: calc(50px + 50vh); + padding-right: 0; + } + } +} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 69fd094f83b..ee4f74882a1 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -95,6 +95,78 @@ } } +.group-home-panel { + margin-top: $gl-padding; + margin-bottom: $gl-padding; + + .home-panel-avatar { + width: $home-panel-title-row-height; + height: $home-panel-title-row-height; + flex-shrink: 0; + flex-basis: $home-panel-title-row-height; + } + + .home-panel-title { + font-size: 20px; + line-height: $gl-line-height-24; + font-weight: bold; + + .icon { + vertical-align: -1px; + } + + .home-panel-topic-list { + font-size: $gl-font-size; + font-weight: $gl-font-weight-normal; + + .icon { + position: relative; + top: 3px; + margin-right: $gl-padding-4; + } + } + } + + .home-panel-title-row { + @include media-breakpoint-down(sm) { + .home-panel-avatar { + width: $home-panel-avatar-mobile-size; + height: $home-panel-avatar-mobile-size; + flex-basis: $home-panel-avatar-mobile-size; + + .avatar { + font-size: 20px; + line-height: 46px; + } + } + + .home-panel-title { + margin-top: 4px; + margin-bottom: 2px; + font-size: $gl-font-size; + line-height: $gl-font-size-large; + } + + .home-panel-topic-list, + .home-panel-metadata { + font-size: $gl-font-size-small; + } + } + } + + .home-panel-metadata { + font-weight: normal; + font-size: 14px; + line-height: $gl-btn-line-height; + } + + .home-panel-description { + @include media-breakpoint-up(md) { + font-size: $gl-font-size-large; + } + } +} + .home-panel-buttons { .home-panel-action-button { vertical-align: top; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 05ade210153..938d8d34717 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1,11 +1,3 @@ -.alert_holder { - margin: -16px; - - .alert-link { - font-weight: $gl-font-weight-normal; - } -} - .new_project, .edit-project, .import-project { @@ -67,38 +59,7 @@ } } -.classification-label { - background-color: $red-500; -} - -.toggle-wrapper { - margin-top: 5px; -} - -.project-feature-row > .toggle-wrapper { - margin: 10px 0; -} - -.project-visibility-setting, -.project-feature-settings { - border: 1px solid $border-color; - padding: 10px 32px; - - @include media-breakpoint-down(xs) { - padding: 10px 20px; - } -} - -.project-visibility-setting .request-access { - line-height: 2; -} - -.project-feature-settings { - background: $gray-lighter; - border-top: 0; - margin-bottom: 16px; -} - +// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components .project-repo-select { transition: background 2s ease-out; @@ -113,63 +74,31 @@ } } +// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components .project-feature-controls { - display: flex; - align-items: center; - margin: $gl-padding-8 0; max-width: 432px; - - .toggle-wrapper { - flex: 0; - margin-right: 10px; - } - - .select-wrapper { - flex: 1; - } } +// INFO Scoped to settings_panel component in app/assets/javascripts/pages/projects/shared/permissions/components .project-feature-setting-group { - padding-left: 32px; - .project-feature-controls { max-width: 400px; } - - @include media-breakpoint-down(xs) { - padding-left: 20px; - } } -.group-home-panel, .project-home-panel { - margin-top: $gl-padding; - margin-bottom: $gl-padding; - .home-panel-avatar { - width: $home-panel-title-row-height; - height: $home-panel-title-row-height; - flex-shrink: 0; flex-basis: $home-panel-title-row-height; } .home-panel-title { - font-size: 20px; - line-height: $gl-line-height-24; - font-weight: bold; - .icon { vertical-align: -1px; } .home-panel-topic-list { - font-size: $gl-font-size; - font-weight: $gl-font-weight-normal; - .icon { - position: relative; top: 3px; - margin-right: $gl-padding-4; } } } @@ -201,24 +130,6 @@ } } - .home-panel-metadata { - font-weight: normal; - font-size: 14px; - line-height: $gl-btn-line-height; - - .home-panel-license { - .btn { - line-height: 0; - border-width: 0; - } - } - - .access-request-link { - padding-left: $gl-padding-8; - border-left: 1px solid $gl-text-color-secondary; - } - } - .home-panel-description { @include media-breakpoint-up(md) { font-size: $gl-font-size-large; diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 2a7dbbb43d7..3ceb60a6aef 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -4,7 +4,8 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload include ContinueParams - before_action :build, except: [:index] + before_action :find_job_as_build, except: [:index, :play] + before_action :find_job_as_processable, only: [:play] before_action :authorize_read_build! before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace, :erase] @@ -44,10 +45,10 @@ class Projects::JobsController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def trace - build.trace.read do |stream| + @build.trace.read do |stream| respond_to do |format| format.json do - build.trace.being_watched! + @build.trace.being_watched! build_trace = Ci::BuildTrace.new( build: @build, @@ -72,8 +73,13 @@ class Projects::JobsController < Projects::ApplicationController def play return respond_422 unless @build.playable? - build = @build.play(current_user, play_params[:job_variables_attributes]) - redirect_to build_path(build) + job = @build.play(current_user, play_params[:job_variables_attributes]) + + if job.is_a?(Ci::Bridge) + redirect_to pipeline_path(job.pipeline) + else + redirect_to build_path(job) + end end def cancel @@ -117,7 +123,7 @@ class Projects::JobsController < Projects::ApplicationController send_params: raw_send_params, redirect_params: raw_redirect_params) else - build.trace.read do |stream| + @build.trace.read do |stream| if stream.file? workhorse_set_content_type! send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' @@ -149,19 +155,19 @@ class Projects::JobsController < Projects::ApplicationController private def authorize_update_build! - return access_denied! unless can?(current_user, :update_build, build) + return access_denied! unless can?(current_user, :update_build, @build) end def authorize_erase_build! - return access_denied! unless can?(current_user, :erase_build, build) + return access_denied! unless can?(current_user, :erase_build, @build) end def authorize_use_build_terminal! - return access_denied! unless can?(current_user, :create_build_terminal, build) + return access_denied! unless can?(current_user, :create_build_terminal, @build) end def authorize_create_proxy_build! - return access_denied! unless can?(current_user, :create_build_service_proxy, build) + return access_denied! unless can?(current_user, :create_build_service_proxy, @build) end def verify_api_request! @@ -186,14 +192,22 @@ class Projects::JobsController < Projects::ApplicationController end def trace_artifact_file - @trace_artifact_file ||= build.job_artifacts_trace&.file + @trace_artifact_file ||= @build.job_artifacts_trace&.file end - def build - @build ||= project.builds.find(params[:id]) + def find_job_as_build + @build = project.builds.find(params[:id]) .present(current_user: current_user) end + def find_job_as_processable + if ::Gitlab::Ci::Features.manual_bridges_enabled?(project) + @build = project.processables.find(params[:id]) + else + find_job_as_build + end + end + def build_path(build) project_job_path(build.project, build) end @@ -208,10 +222,10 @@ class Projects::JobsController < Projects::ApplicationController end def build_service_specification - build.service_specification(service: params['service'], - port: params['port'], - path: params['path'], - subprotocols: proxy_subprotocol) + @build.service_specification(service: params['service'], + port: params['port'], + path: params['path'], + subprotocols: proxy_subprotocol) end def proxy_subprotocol diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 1697067f633..2e725e0baff 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -27,7 +27,7 @@ module Ci # rubocop:enable Cop/ActiveRecordSerialize state_machine :status do - after_transition created: :pending do |bridge| + after_transition [:created, :manual] => :pending do |bridge| next unless bridge.downstream_project bridge.run_after_commit do @@ -46,6 +46,10 @@ module Ci event :scheduled do transition all => :scheduled end + + event :actionize do + transition created: :manual + end end def self.retry(bridge, current_user) @@ -126,9 +130,27 @@ module Ci false end + def playable? + return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) + + action? && !archived? && manual? + end + def action? - false + return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) + + %w[manual].include?(self.when) + end + + # rubocop: disable CodeReuse/ServiceClass + # We don't need it but we are taking `job_variables_attributes` parameter + # to make it consistent with `Ci::Build#play` method. + def play(current_user, job_variables_attributes = nil) + Ci::PlayBridgeService + .new(project, current_user) + .execute(self) end + # rubocop: enable CodeReuse/ServiceClass def artifacts? false @@ -185,6 +207,10 @@ module Ci [] end + def target_revision_ref + downstream_pipeline_params.dig(:target_revision, :ref) + end + private def cross_project_params diff --git a/app/models/project.rb b/app/models/project.rb index 71816dfb9ba..9fa93d9b4e4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -298,6 +298,7 @@ class Project < ApplicationRecord # bulk that doesn't involve loading the rows into memory. As a result we're # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :processables, class_name: 'Ci::Processable', inverse_of: :project has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 2ff2e3d66c0..9d88db27449 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -37,7 +37,11 @@ module Terraform end def latest_file - versioning_enabled ? latest_version&.file : file + if versioning_enabled? + latest_version&.file + else + latest_version&.file || file + end end def locked? @@ -46,13 +50,56 @@ module Terraform def update_file!(data, version:) if versioning_enabled? - new_version = versions.build(version: version) - new_version.assign_attributes(created_by_user: locked_by_user, file: data) - new_version.save! + create_new_version!(data: data, version: version) + elsif latest_version.present? + migrate_legacy_version!(data: data, version: version) else self.file = data save! end end + + private + + ## + # If a Terraform state was created before versioning support was + # introduced, it will have a single version record whose file + # uses a legacy naming scheme in object storage. To update + # these states and versions to use the new behaviour, we must do + # the following when creating the next version: + # + # * Read the current, non-versioned file from the old location. + # * Update the :versioning_enabled flag, which determines the + # naming scheme + # * Resave the existing file with the updated name and location, + # using a version number one prior to the new version + # * Create the new version as normal + # + # This migration only needs to happen once for each state, from + # then on the state will behave as if it was always versioned. + # + # The code can be removed in the next major version (14.0), after + # which any states that haven't been migrated will need to be + # recreated: https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + def migrate_legacy_version!(data:, version:) + current_file = latest_version.file.read + current_version = parse_serial(current_file) || version - 1 + + update!(versioning_enabled: true) + + reload_latest_version.update!(version: current_version, file: CarrierWaveStringFile.new(current_file)) + create_new_version!(data: data, version: version) + end + + def create_new_version!(data:, version:) + new_version = versions.build(version: version, created_by_user: locked_by_user) + new_version.assign_attributes(file: data) + new_version.save! + end + + def parse_serial(file) + Gitlab::Json.parse(file)["serial"] + rescue JSON::ParserError + end end end diff --git a/app/policies/ci/bridge_policy.rb b/app/policies/ci/bridge_policy.rb new file mode 100644 index 00000000000..37a07ea8aaf --- /dev/null +++ b/app/policies/ci/bridge_policy.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ci + class BridgePolicy < CommitStatusPolicy + condition(:can_update_downstream_branch) do + ::Gitlab::UserAccess.new(@user, container: @subject.downstream_project) + .can_update_branch?(@subject.target_revision_ref) + end + + rule { can_update_downstream_branch }.enable :play_job + end +end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index b3950c6a0e3..3efc07421e4 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -60,6 +60,8 @@ module Ci rule { can?(:update_build) & terminal }.enable :create_build_terminal + rule { can?(:update_build) }.enable :play_job + rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do enable :read_web_ide_terminal enable :update_web_ide_terminal diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb new file mode 100644 index 00000000000..70c4a8e6136 --- /dev/null +++ b/app/services/ci/play_bridge_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ci + class PlayBridgeService < ::BaseService + def execute(bridge) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, bridge) + + bridge.tap do |bridge| + bridge.user = current_user + bridge.enqueue! + end + end + end +end diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index 9f922ffde81..6adeca624a8 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -3,9 +3,7 @@ module Ci class PlayBuildService < ::BaseService def execute(build, job_variables_attributes = nil) - unless can?(current_user, :update_build, build) - raise Gitlab::Access::AccessDeniedError - end + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build) # Try to enqueue the build, otherwise create a duplicate. # diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb index 2497fc52e6b..c6fa7803e52 100644 --- a/app/services/ci/play_manual_stage_service.rb +++ b/app/services/ci/play_manual_stage_service.rb @@ -9,12 +9,12 @@ module Ci end def execute(stage) - stage.builds.manual.each do |build| - next unless build.playable? + stage.processables.manual.each do |processable| + next unless processable.playable? - build.play(current_user) + processable.play(current_user) rescue Gitlab::Access::AccessDeniedError - logger.error(message: 'Unable to play manual action', build_id: build.id) + logger.error(message: 'Unable to play manual action', processable_id: processable.id) end end diff --git a/app/uploaders/terraform/versioned_state_uploader.rb b/app/uploaders/terraform/versioned_state_uploader.rb index be07993da0f..e50ab6c7dc6 100644 --- a/app/uploaders/terraform/versioned_state_uploader.rb +++ b/app/uploaders/terraform/versioned_state_uploader.rb @@ -2,12 +2,22 @@ module Terraform class VersionedStateUploader < StateUploader + delegate :terraform_state, to: :model + def filename - "#{model.version}.tfstate" + if terraform_state.versioning_enabled? + "#{model.version}.tfstate" + else + "#{model.uuid}.tfstate" + end end def store_dir - Gitlab::HashedPath.new(model.uuid, root_hash: project_id) + if terraform_state.versioning_enabled? + Gitlab::HashedPath.new(model.uuid, root_hash: project_id) + else + project_id.to_s + end end end end diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml index cc4caf079b8..d76fb50aa0b 100644 --- a/app/views/layouts/nav/_classification_level_banner.html.haml +++ b/app/views/layouts/nav/_classification_level_banner.html.haml @@ -1,5 +1,5 @@ - if ::Gitlab::ExternalAuthorization.enabled? && @project = content_for :header_content do - %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') } + %span.badge.color-label.gl-bg-red-500.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') } = sprite_icon('lock-open', size: 8, css_class: 'inline') = @project.external_authorization_classification_label diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 94a2bdb3bcb..9f4496e7a13 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,19 +3,19 @@ - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? -.project-home-panel.js-show-on-project-root{ class: [("empty-project" if empty_repo)] } +.project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] } .row.gl-mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none + .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.gl-w-11.gl-h-11.gl-mr-3.float-none = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2{ data: { qa_selector: 'project_name_content' } } + %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold{ data: { qa_selector: 'project_name_content' } } = @project.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project - .home-panel-metadata.d-flex.flex-wrap.text-secondary + .home-panel-metadata.d-flex.flex-wrap.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal - if can?(current_user, :read_project, @project) %span.text-secondary = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } @@ -23,8 +23,8 @@ %span.access-request-links.gl-ml-3 = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? - %span.home-panel-topic-list.mt-2.w-100.d-inline-flex - = sprite_icon('tag', css_class: 'icon gl-mr-2') + %span.home-panel-topic-list.mt-2.w-100.d-inline-flex.gl-font-base.gl-font-weight-normal + = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - @project.topics_to_show.each do |topic| - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 643d111fedd..30b0631b465 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -1,12 +1,15 @@ -.alert.alert-warning - %h4 +.gl-alert.gl-alert-warning.gl-mb-5 + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon') + %h4.gl-alert-title = _("Too many changes to show.") - .float-right - - if current_controller?(:commit) - = link_to _("Plain diff"), project_commit_path(@project, @commit, format: :diff), class: "btn btn-sm" - = link_to _("Email patch"), project_commit_path(@project, @commit, format: :patch), class: "btn btn-sm" - - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted? - = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" - = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" - %p + .gl-alert-body = html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } + .gl-alert-actions + - if current_controller?(:commit) + = link_to _("Plain diff"), project_commit_path(@project, @commit, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary" + = link_to _("Email patch"), project_commit_path(@project, @commit, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary" + - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted? + = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary" + = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d33023f66d6..1dbcd613ceb 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -8,6 +8,7 @@ - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') - number_of_pipelines = @pipelines.size - mr_action = j(params[:tab].presence || 'show') +- add_page_specific_style 'page_bundles/merge_requests' - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/reports' diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index b4b06640bd9..a983a736a1e 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -5,13 +5,13 @@ = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' }, - class: 'access-request-link js-leave-link' + class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500 js-leave-link' - elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), method: :delete, data: { confirm: remove_member_message(requester) }, - class: 'access-request-link' + class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500' - elsif source.request_access_enabled && can?(current_user, :request_access, source) = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), method: :post, - class: 'access-request-link' + class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500' diff --git a/changelogs/unreleased/233694-replace-bootstrap-alerts-in-app-views-projects-diffs-_warning-html.yml b/changelogs/unreleased/233694-replace-bootstrap-alerts-in-app-views-projects-diffs-_warning-html.yml new file mode 100644 index 00000000000..ec4537484fe --- /dev/null +++ b/changelogs/unreleased/233694-replace-bootstrap-alerts-in-app-views-projects-diffs-_warning-html.yml @@ -0,0 +1,5 @@ +--- +title: Replace bootstrap alerts in app/views/projects/diffs/_warning.html.haml +merge_request: 41295 +author: Gilang Gumilar +type: changed diff --git a/changelogs/unreleased/235108-migrate-terraform-states-to-versioniong.yml b/changelogs/unreleased/235108-migrate-terraform-states-to-versioniong.yml new file mode 100644 index 00000000000..90f89077c0c --- /dev/null +++ b/changelogs/unreleased/235108-migrate-terraform-states-to-versioniong.yml @@ -0,0 +1,5 @@ +--- +title: Seed initial version for non-versioned terraform states +merge_request: 43665 +author: +type: added diff --git a/changelogs/unreleased/eread-migrate-tooltip-time-track-collapsed.yml b/changelogs/unreleased/eread-migrate-tooltip-time-track-collapsed.yml new file mode 100644 index 00000000000..e519f650ef3 --- /dev/null +++ b/changelogs/unreleased/eread-migrate-tooltip-time-track-collapsed.yml @@ -0,0 +1,5 @@ +--- +title: Migrate collapsed time tracking tooltip +merge_request: 44874 +author: +type: other diff --git a/changelogs/unreleased/improve-gfm-ac-emoji.yml b/changelogs/unreleased/improve-gfm-ac-emoji.yml new file mode 100644 index 00000000000..c8eaea58005 --- /dev/null +++ b/changelogs/unreleased/improve-gfm-ac-emoji.yml @@ -0,0 +1,5 @@ +--- +title: Match against description and unicode character when autocompleting GFM emoji +merge_request: 42669 +author: Ethan Reesor (@firelizzard) +type: added diff --git a/config/application.rb b/config/application.rb index ee4a05e9726..9c8a7d946b1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -182,6 +182,7 @@ module Gitlab config.assets.precompile << "page_bundles/issues_list.css" config.assets.precompile << "page_bundles/jira_connect.css" config.assets.precompile << "page_bundles/merge_conflicts.css" + config.assets.precompile << "page_bundles/merge_requests.css" config.assets.precompile << "page_bundles/milestone.css" config.assets.precompile << "page_bundles/pipeline.css" config.assets.precompile << "page_bundles/pipelines.css" diff --git a/config/feature_flags/development/ci_manual_bridges.yml b/config/feature_flags/development/ci_manual_bridges.yml new file mode 100644 index 00000000000..f654839e37c --- /dev/null +++ b/config/feature_flags/development/ci_manual_bridges.yml @@ -0,0 +1,7 @@ +--- +name: ci_manual_bridges +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44011 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263412 +type: development +group: group::pipeline authoring +default_enabled: false diff --git a/config/initializers/sprockets.rb b/config/initializers/sprockets.rb index a20b7dc75e9..bd33245d470 100644 --- a/config/initializers/sprockets.rb +++ b/config/initializers/sprockets.rb @@ -1 +1,3 @@ +require 'terser' + Sprockets.register_compressor 'application/javascript', :terser, Terser::Compressor diff --git a/db/post_migrate/20200929052138_create_initial_versions_for_pre_versioning_terraform_states.rb b/db/post_migrate/20200929052138_create_initial_versions_for_pre_versioning_terraform_states.rb new file mode 100644 index 00000000000..eff6ebfe5b4 --- /dev/null +++ b/db/post_migrate/20200929052138_create_initial_versions_for_pre_versioning_terraform_states.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateInitialVersionsForPreVersioningTerraformStates < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + execute <<-SQL + INSERT INTO terraform_state_versions (terraform_state_id, created_at, updated_at, version, file_store, file) + SELECT id, NOW(), NOW(), 0, file_store, file + FROM terraform_states + WHERE versioning_enabled = FALSE + ON CONFLICT (terraform_state_id, version) DO NOTHING + SQL + end + + def down + end +end diff --git a/db/schema_migrations/20200929052138 b/db/schema_migrations/20200929052138 new file mode 100644 index 00000000000..05c56a31270 --- /dev/null +++ b/db/schema_migrations/20200929052138 @@ -0,0 +1 @@ +30b84d137fcb17eaca86f1bec52d6e20c972f7083d4c983e2bb397c9126b5f0c
\ No newline at end of file diff --git a/doc/articles/artifactory_and_gitlab/index.md b/doc/articles/artifactory_and_gitlab/index.md deleted file mode 100644 index ed9fd135e7c..00000000000 --- a/doc/articles/artifactory_and_gitlab/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../ci/examples/artifactory_and_gitlab/index.md' ---- - -This document was moved to [another location](../../ci/examples/artifactory_and_gitlab/index.md) diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md deleted file mode 100644 index 2fbeb6f2506..00000000000 --- a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../administration/auth/ldap/index.md' ---- - -This document was moved to [another location](../../administration/auth/ldap/index.md). diff --git a/doc/articles/how_to_configure_ldap_gitlab_ee/index.md b/doc/articles/how_to_configure_ldap_gitlab_ee/index.md deleted file mode 100644 index 2fbeb6f2506..00000000000 --- a/doc/articles/how_to_configure_ldap_gitlab_ee/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../administration/auth/ldap/index.md' ---- - -This document was moved to [another location](../../administration/auth/ldap/index.md). diff --git a/doc/articles/how_to_install_git/index.md b/doc/articles/how_to_install_git/index.md deleted file mode 100644 index 62598101895..00000000000 --- a/doc/articles/how_to_install_git/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../topics/git/how_to_install_git/index.md' ---- - -This document was moved to [another location](../../topics/git/how_to_install_git/index.md). diff --git a/doc/articles/index.md b/doc/articles/index.md deleted file mode 100644 index 4b965b0256f..00000000000 --- a/doc/articles/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../topics/index.md' ---- - -This document was moved to [another location](../topics/index.md) diff --git a/doc/articles/laravel_with_gitlab_and_envoy/index.md b/doc/articles/laravel_with_gitlab_and_envoy/index.md deleted file mode 100644 index fa4f6243410..00000000000 --- a/doc/articles/laravel_with_gitlab_and_envoy/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../ci/examples/laravel_with_gitlab_and_envoy/index.md' ---- - -This document was moved to [another location](../../ci/examples/laravel_with_gitlab_and_envoy/index.md). diff --git a/doc/articles/numerous_undo_possibilities_in_git/index.md b/doc/articles/numerous_undo_possibilities_in_git/index.md deleted file mode 100644 index 83aac82db4e..00000000000 --- a/doc/articles/numerous_undo_possibilities_in_git/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../../topics/git/numerous_undo_possibilities_in_git/index.md' ---- - -This document was moved to [another location](../../topics/git/numerous_undo_possibilities_in_git/index.md). diff --git a/doc/articles/openshift_and_gitlab/index.md b/doc/articles/openshift_and_gitlab/index.md deleted file mode 100644 index 822d012aa3d..00000000000 --- a/doc/articles/openshift_and_gitlab/index.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -redirect_to: '../../install/openshift_and_gitlab/index.html' ---- diff --git a/doc/articles/runner_autoscale_aws/index.md b/doc/articles/runner_autoscale_aws/index.md deleted file mode 100644 index fb769731256..00000000000 --- a/doc/articles/runner_autoscale_aws/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: 'https://docs.gitlab.com/runner/configuration/runner_autoscale_aws/index.html' ---- - -This document was moved to [another location](https://docs.gitlab.com/runner/configuration/runner_autoscale_aws/index.html). diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0ead0695acc..26674bb86eb 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -2390,9 +2390,9 @@ To trigger a manual job, a user must have permission to merge to the assigned br You can use [protected branches](../../user/project/protected_branches.md) to more strictly [protect manual deployments](#protecting-manual-jobs) from being run by unauthorized users. -`when:manual` and [`trigger`](#trigger) cannot be used together. If you use both in -the same job, you receive a `jobs:#{job-name} when should be on_success, on_failure or always` -error. +In [GitLab 13.5](https://gitlab.com/gitlab-org/gitlab/-/issues/201938) and later, you +can use `when:manual` in the same job as [`trigger`](#trigger). In GitLab 13.4 and +earlier, using them together causes the error `jobs:#{job-name} when should be on_success, on_failure or always`. ##### Protecting manual jobs **(PREMIUM)** @@ -3643,10 +3643,9 @@ You can use this keyword to create two different types of downstream pipelines: see which job triggered a downstream pipeline by hovering your mouse cursor over the downstream pipeline job in the [pipeline graph](../pipelines/index.md#visualize-pipelines). -NOTE: **Note:** -Using a `trigger` with `when:manual` together results in the error `jobs:#{job-name} -when should be on_success, on_failure or always`, because `when:manual` prevents -triggers being used. +In [GitLab 13.5](https://gitlab.com/gitlab-org/gitlab/-/issues/201938) and later, you +can use [`when:manual`](#whenmanual) in the same job as `trigger`. In GitLab 13.4 and +earlier, using them together causes the error `jobs:#{job-name} when should be on_success, on_failure or always`. #### Simple `trigger` syntax for multi-project pipelines diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md deleted file mode 100644 index b98d1b51999..00000000000 --- a/doc/container_registry/README.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/packages/container_registry/index.md' ---- - -This document was moved to [another location](../user/packages/container_registry/index.md). diff --git a/doc/container_registry/troubleshooting.md b/doc/container_registry/troubleshooting.md deleted file mode 100644 index 092d7831e35..00000000000 --- a/doc/container_registry/troubleshooting.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/packages/container_registry/index.md#troubleshooting-the-gitlab-container-registry' ---- - -This document was moved to [user/project/container_registry](../user/packages/container_registry/index.md#troubleshooting-the-gitlab-container-registry). diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index ed84eab48db..20ed0cbc39e 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -287,9 +287,8 @@ The table below shows what kind of documentation goes where. 1. The `doc/topics/` directory holds topic-related technical content. Create `doc/topics/topic_name/subtopic_name/index.md` when subtopics become necessary. General user- and admin- related documentation, should be placed accordingly. -1. The directories `/workflow/`, `/university/`, and `/articles/` have been - *deprecated* and the majority their documentation has been moved to their - correct location in small iterations. +1. The `/university/` directory is *deprecated* and the majority of its documentation + has been moved. If you are unsure where to place a document or a content addition, this should not stop you from authoring and contributing. You can use your best judgment and diff --git a/doc/getting-started/subscription.md b/doc/getting-started/subscription.md deleted file mode 100644 index 8bcd11c20c8..00000000000 --- a/doc/getting-started/subscription.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -redirect_to: '../subscriptions/index.md' ---- diff --git a/doc/git_hooks/git_hooks.md b/doc/git_hooks/git_hooks.md deleted file mode 100644 index b251e58410a..00000000000 --- a/doc/git_hooks/git_hooks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../push_rules/push_rules.md' ---- - -This document was moved to [another location](../push_rules/push_rules.md) diff --git a/doc/hooks/custom_hooks.md b/doc/hooks/custom_hooks.md deleted file mode 100644 index c6d44bb03e9..00000000000 --- a/doc/hooks/custom_hooks.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -redirect_to: '../administration/server_hooks.md' ---- - -# Custom Git Hooks - -This document was moved to [administration/server_hooks.md](../administration/server_hooks.md). diff --git a/doc/incoming_email/README.md b/doc/incoming_email/README.md deleted file mode 100644 index 9544983974f..00000000000 --- a/doc/incoming_email/README.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../administration/reply_by_email.md' ---- - -This document was moved to [administration/reply_by_email](../administration/reply_by_email.md). diff --git a/doc/incoming_email/postfix.md b/doc/incoming_email/postfix.md deleted file mode 100644 index a7192325229..00000000000 --- a/doc/incoming_email/postfix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../administration/reply_by_email_postfix_setup.md' ---- - -This document was moved to [administration/reply_by_email_postfix_setup](../administration/reply_by_email_postfix_setup.md). diff --git a/doc/logs/logs.md b/doc/logs/logs.md deleted file mode 100644 index 0cb092c85fd..00000000000 --- a/doc/logs/logs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../administration/logs.md' ---- - -This document was moved to [administration/logs.md](../administration/logs.md). diff --git a/doc/pages/README.md b/doc/pages/README.md deleted file mode 100644 index c67847f1a83..00000000000 --- a/doc/pages/README.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/project/pages/index.md' ---- - -This document was moved to [another location](../user/project/pages/index.md). diff --git a/doc/pages/administration.md b/doc/pages/administration.md deleted file mode 100644 index 015dd54ec7f..00000000000 --- a/doc/pages/administration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../administration/pages/index.md' ---- - -This document was moved to [another location](../administration/pages/index.md). diff --git a/doc/pages/getting_started_part_one.md b/doc/pages/getting_started_part_one.md deleted file mode 100644 index a0feed0b477..00000000000 --- a/doc/pages/getting_started_part_one.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/project/pages/getting_started_part_one.md' ---- - -This document was moved to [another location](../user/project/pages/getting_started_part_one.md). diff --git a/doc/pages/getting_started_part_three.md b/doc/pages/getting_started_part_three.md deleted file mode 100644 index 31a01a6c83b..00000000000 --- a/doc/pages/getting_started_part_three.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/project/pages/custom_domains_ssl_tls_certification/index.md' ---- - -This document was moved to [another location](../user/project/pages/custom_domains_ssl_tls_certification/index.md). diff --git a/doc/pages/getting_started_part_two.md b/doc/pages/getting_started_part_two.md deleted file mode 100644 index 05353c171fc..00000000000 --- a/doc/pages/getting_started_part_two.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/project/pages/getting_started_part_two.md' ---- - -This document was moved to [another location](../user/project/pages/getting_started_part_two.md). diff --git a/doc/profile/README.md b/doc/profile/README.md deleted file mode 100644 index 4932cf33b87..00000000000 --- a/doc/profile/README.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/profile/index.md' ---- - -This document was moved to [user/profile/account](../user/profile/index.md). diff --git a/doc/profile/preferences.md b/doc/profile/preferences.md deleted file mode 100644 index cf99bd61f5d..00000000000 --- a/doc/profile/preferences.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/profile/preferences.md' ---- - -This document was moved to [another location](../user/profile/preferences.md). diff --git a/doc/profile/two_factor_authentication.md b/doc/profile/two_factor_authentication.md deleted file mode 100644 index 453ac833f59..00000000000 --- a/doc/profile/two_factor_authentication.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/profile/account/two_factor_authentication.md' ---- - -This document was moved to [user/profile/account](../user/profile/account/two_factor_authentication.md). diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index 09a70dda60d..bc8df63e33f 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -61,7 +61,7 @@ Access the default page for admin area settings by navigating to **Admin Area > | ------ | ----------- | | [Continuous Integration and Deployment](continuous_integration.md) | Auto DevOps, runners and job artifacts. | | [Required pipeline configuration](continuous_integration.md#required-pipeline-configuration) **(PREMIUM ONLY)** | Set an instance-wide auto included [pipeline configuration](../../../ci/yaml/README.md). This pipeline configuration will be run after the project's own configuration. | -| [Package Registry](continuous_integration.md#package-registry-configuration) | Settings related to the use and experience of using GitLab's Package Registry. Note there are [risks involved](./../../packages/container_registry/index.md#use-with-external-container-registries) in enabling some of these settings. | +| [Package Registry](continuous_integration.md#package-registry-configuration) | Settings related to the use and experience of using GitLab's Package Registry. Note there are [risks involved](../../packages/container_registry/index.md#use-with-external-container-registries) in enabling some of these settings. | ## Reporting diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md deleted file mode 100644 index fffb6a5d86d..00000000000 --- a/doc/web_hooks/web_hooks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -redirect_to: '../user/project/integrations/webhooks.md' ---- - -This document was moved to [project/integrations/webhooks](../user/project/integrations/webhooks.md). diff --git a/lib/gitlab/ci/config/entry/bridge.rb b/lib/gitlab/ci/config/entry/bridge.rb index a8b67a1db4f..1740032e5c7 100644 --- a/lib/gitlab/ci/config/entry/bridge.rb +++ b/lib/gitlab/ci/config/entry/bridge.rb @@ -11,15 +11,18 @@ module Gitlab class Bridge < ::Gitlab::Config::Entry::Node include ::Gitlab::Ci::Config::Entry::Processable + ALLOWED_WHEN = %w[on_success on_failure always manual].freeze ALLOWED_KEYS = %i[trigger].freeze validations do validates :config, allowed_keys: ALLOWED_KEYS + PROCESSABLE_ALLOWED_KEYS with_options allow_nil: true do - validates :when, - inclusion: { in: %w[on_success on_failure always], - message: 'should be on_success, on_failure or always' } + validates :allow_failure, boolean: true + validates :when, inclusion: { + in: ALLOWED_WHEN, + message: "should be one of: #{ALLOWED_WHEN.join(', ')}" + } end validate on: :composed do @@ -57,11 +60,19 @@ module Gitlab true end + def manual_action? + self.when == 'manual' + end + + def ignored? + allow_failure.nil? ? manual_action? : allow_failure + end + def value super.merge( trigger: (trigger_value if trigger_defined?), needs: (needs_value if needs_defined?), - ignore: !!allow_failure, + ignore: ignored?, when: self.when, scheduling_type: needs_defined? && !bridge_needs ? :dag : :stage ).compact diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb index 2b485167010..5bb6234bbb8 100644 --- a/lib/gitlab/ci/features.rb +++ b/lib/gitlab/ci/features.rb @@ -70,6 +70,10 @@ module Gitlab def self.one_dimensional_matrix_enabled? ::Feature.enabled?(:one_dimensional_matrix, default_enabled: false) end + + def self.manual_bridges_enabled?(project) + ::Feature.enabled?(:ci_manual_bridges, project, default_enabled: false) + end end end end diff --git a/lib/gitlab/ci/status/bridge/action.rb b/lib/gitlab/ci/status/bridge/action.rb new file mode 100644 index 00000000000..1ba4700d9b0 --- /dev/null +++ b/lib/gitlab/ci/status/bridge/action.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Bridge + class Action < Status::Build::Action + end + end + end + end +end diff --git a/lib/gitlab/ci/status/bridge/factory.rb b/lib/gitlab/ci/status/bridge/factory.rb index 5d397dba0de..b9bd66cee71 100644 --- a/lib/gitlab/ci/status/bridge/factory.rb +++ b/lib/gitlab/ci/status/bridge/factory.rb @@ -6,7 +6,10 @@ module Gitlab module Bridge class Factory < Status::Factory def self.extended_statuses - [Status::Bridge::Failed] + [[Status::Bridge::Failed], + [Status::Bridge::Manual], + [Status::Bridge::Play], + [Status::Bridge::Action]] end def self.common_helpers diff --git a/lib/gitlab/ci/status/bridge/manual.rb b/lib/gitlab/ci/status/bridge/manual.rb new file mode 100644 index 00000000000..e07e645a34d --- /dev/null +++ b/lib/gitlab/ci/status/bridge/manual.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Bridge + class Manual < Status::Build::Manual + end + end + end + end +end diff --git a/lib/gitlab/ci/status/bridge/play.rb b/lib/gitlab/ci/status/bridge/play.rb new file mode 100644 index 00000000000..ae00ef6c2ad --- /dev/null +++ b/lib/gitlab/ci/status/bridge/play.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Bridge + class Play < Status::Build::Play + def has_action? + can?(user, :play_job, subject) + end + + def self.matches?(bridge, user) + bridge.playable? + end + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 008f679e583..4db6f3b8b55 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -746,6 +746,9 @@ msgid_plural "%{securityScanner} results are not available because a pipeline ha msgstr[0] "" msgstr[1] "" +msgid "%{size} %{unit}" +msgstr "" + msgid "%{size} GiB" msgstr "" @@ -28102,6 +28105,12 @@ msgstr "" msgid "UsageQuota|LFS Storage" msgstr "" +msgid "UsageQuota|Learn more about excess storage usage" +msgstr "" + +msgid "UsageQuota|Learn more about usage quotas" +msgstr "" + msgid "UsageQuota|Packages" msgstr "" @@ -28111,6 +28120,9 @@ msgstr "" msgid "UsageQuota|Purchase more storage" msgstr "" +msgid "UsageQuota|Purchased storage available" +msgstr "" + msgid "UsageQuota|Repositories" msgstr "" @@ -28123,6 +28135,9 @@ msgstr "" msgid "UsageQuota|Storage" msgstr "" +msgid "UsageQuota|This namespace contains locked projects" +msgstr "" + msgid "UsageQuota|This namespace has no projects which use shared runners" msgstr "" @@ -28132,6 +28147,12 @@ msgstr "" msgid "UsageQuota|This project is locked." msgstr "" +msgid "UsageQuota|Total excess storage used" +msgstr "" + +msgid "UsageQuota|Total namespace storage used" +msgstr "" + msgid "UsageQuota|Unlimited" msgstr "" @@ -30193,6 +30214,9 @@ msgstr "" msgid "Your projects" msgstr "" +msgid "Your purchased storage is running low. To avoid locked projects, please purchase more storage." +msgstr "" + msgid "Your request for access could not be processed: %{error_meesage}" msgstr "" diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb index 57ab7fb4480..95759d3b603 100644 --- a/qa/qa/page/project/pipeline/show.rb +++ b/qa/qa/page/project/pipeline/show.rb @@ -16,8 +16,9 @@ module QA end view 'app/assets/javascripts/pipelines/components/graph/job_item.vue' do - element :job_component, /class.*ci-job-component.*/ # rubocop:disable QA/ElementWithPattern + element :job_item_container element :job_link + element :action_button end view 'app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue' do @@ -40,10 +41,12 @@ module QA end def has_build?(name, status: :success, wait: nil) - within('.pipeline-graph') do - within('.ci-job-component', text: name) do + if status + within_element(:job_item_container, text: name) do has_selector?(".ci-status-icon-#{status}", { wait: wait }.compact) end + else + has_element?(:job_item_container, text: name) end end @@ -78,6 +81,12 @@ module QA def click_on_first_job first('.js-pipeline-graph-job-link', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME).click end + + def click_job_action(job_name) + within_element(:job_item_container, text: job_name) do + click_element(:action_button) + end + end end end end diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/trigger_child_pipeline_with_manual_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/trigger_child_pipeline_with_manual_spec.rb new file mode 100644 index 00000000000..39d5fbaba6b --- /dev/null +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/trigger_child_pipeline_with_manual_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'faker' + +module QA + RSpec.describe 'Verify', :runner, :requires_admin do + # [TODO]: Developer to remove :requires_admin once FF is removed in follow up issue + + describe "Trigger child pipeline with 'when:manual'" do + let(:feature_flag) { :ci_manual_bridges } # [TODO]: Developer to remove when feature flag is removed + let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(8)}" } + + let(:project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'project-with-pipeline' + end + end + + let!(:runner) do + Resource::Runner.fabricate! do |runner| + runner.project = project + runner.name = executor + runner.tags = [executor] + end + end + + before do + Runtime::Feature.enable(feature_flag) # [TODO]: Developer to remove when feature flag is removed + Flow::Login.sign_in + add_ci_files + project.visit! + view_the_last_pipeline + end + + after do + Runtime::Feature.disable(feature_flag) # [TODO]: Developer to remove when feature flag is removed + runner.remove_via_api! + end + + it 'can trigger bridge job', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1049' do + Page::Project::Pipeline::Show.perform do |parent_pipeline| + expect(parent_pipeline).not_to have_child_pipeline + + parent_pipeline.click_job_action('trigger') + Support::Waiter.wait_until { parent_pipeline.has_child_pipeline? } + parent_pipeline.expand_child_pipeline + + expect(parent_pipeline).to have_build('child_build', status: nil) + end + end + + private + + def add_ci_files + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.project = project + commit.commit_message = 'Add parent and child pipelines CI files.' + commit.add_files( + [ + child_ci_file, + parent_ci_file + ] + ) + end + end + + def view_the_last_pipeline + Page::Project::Menu.perform(&:click_ci_cd_pipelines) + Page::Project::Pipeline::Index.perform(&:wait_for_latest_pipeline_success) + Page::Project::Pipeline::Index.perform(&:click_on_latest_pipeline) + end + + def parent_ci_file + { + file_path: '.gitlab-ci.yml', + content: <<~YAML + build: + stage: build + tags: ["#{executor}"] + script: echo build + + trigger: + stage: test + when: manual + trigger: + include: '.child-pipeline.yml' + + deploy: + stage: deploy + tags: ["#{executor}"] + script: echo deploy + YAML + } + end + + def child_ci_file + { + file_path: '.child-pipeline.yml', + content: <<~YAML + child_build: + stage: build + tags: ["#{executor}"] + script: echo build + YAML + } + end + end + end +end diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh index 11550c748ee..0ce989738df 100755 --- a/scripts/lint-doc.sh +++ b/scripts/lint-doc.sh @@ -40,8 +40,8 @@ then fi # Do not use 'README.md', instead use 'index.md' -# Number of 'README.md's as of 2020-05-28 -NUMBER_READMES=40 +# Number of 'README.md's as of 2020-10-13 +NUMBER_READMES=36 FIND_READMES=$(find doc/ -name "README.md" | wc -l) echo '=> Checking for new README.md files...' echo diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index eff98ab65a6..80cb16966e5 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -757,19 +757,21 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do name: 'master', project: project) sign_in(user) - - post_play end context 'when job is playable' do let(:job) { create(:ci_build, :playable, pipeline: pipeline) } it 'redirects to the played job page' do + post_play + expect(response).to have_gitlab_http_status(:found) expect(response).to redirect_to(namespace_project_job_path(id: job.id)) end it 'transits to pending' do + post_play + expect(job.reload).to be_pending end @@ -777,15 +779,54 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do let(:variable_attributes) { [{ key: 'first', secret_value: 'first' }] } it 'assigns the job variables' do + post_play + expect(job.reload.job_variables.map(&:key)).to contain_exactly('first') end end + + context 'when job is bridge' do + let(:downstream_project) { create(:project) } + let(:job) { create(:ci_bridge, :playable, pipeline: pipeline, downstream: downstream_project) } + + before do + downstream_project.add_developer(user) + end + + it 'redirects to the pipeline page' do + post_play + + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(pipeline_path(pipeline)) + builds_namespace_project_pipeline_path(id: pipeline.id) + end + + it 'transits to pending' do + post_play + + expect(job.reload).to be_pending + end + + context 'when FF ci_manual_bridges is disabled' do + before do + stub_feature_flags(ci_manual_bridges: false) + end + + it 'returns 404' do + post_play + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end context 'when job is not playable' do let(:job) { create(:ci_build, pipeline: pipeline) } it 'renders unprocessable_entity' do + post_play + expect(response).to have_gitlab_http_status(:unprocessable_entity) end end diff --git a/spec/controllers/projects/pipelines/stages_controller_spec.rb b/spec/controllers/projects/pipelines/stages_controller_spec.rb index 6e8c08d95a1..a8b328c7563 100644 --- a/spec/controllers/projects/pipelines/stages_controller_spec.rb +++ b/spec/controllers/projects/pipelines/stages_controller_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Projects::Pipelines::StagesController do let(:user) { create(:user) } let(:project) { create(:project, :repository) } + let(:downstream_project) { create(:project, :repository) } before do sign_in(user) @@ -17,6 +18,7 @@ RSpec.describe Projects::Pipelines::StagesController do before do create_manual_build(pipeline, 'test', 'rspec 1/2') create_manual_build(pipeline, 'test', 'rspec 2/2') + create_manual_bridge(pipeline, 'test', 'trigger') pipeline.reload end @@ -32,6 +34,7 @@ RSpec.describe Projects::Pipelines::StagesController do context 'when user has access' do before do project.add_maintainer(user) + downstream_project.add_maintainer(user) end context 'when the stage does not exists' do @@ -46,12 +49,12 @@ RSpec.describe Projects::Pipelines::StagesController do context 'when the stage exists' do it 'starts all manual jobs' do - expect(pipeline.builds.manual.count).to eq(2) + expect(pipeline.processables.manual.count).to eq(3) play_manual_stage! expect(response).to have_gitlab_http_status(:ok) - expect(pipeline.builds.manual.count).to eq(0) + expect(pipeline.processables.manual.count).to eq(0) end end end @@ -68,5 +71,9 @@ RSpec.describe Projects::Pipelines::StagesController do def create_manual_build(pipeline, stage, name) create(:ci_build, :manual, pipeline: pipeline, stage: stage, name: name) end + + def create_manual_bridge(pipeline, stage, name) + create(:ci_bridge, :manual, pipeline: pipeline, stage: stage, name: name, downstream: downstream_project) + end end end diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb index 5a33a30921b..7727a468633 100644 --- a/spec/factories/ci/bridge.rb +++ b/spec/factories/ci/bridge.rb @@ -40,6 +40,10 @@ FactoryBot.define do end end + trait :created do + status { 'created' } + end + trait :started do started_at { '2013-10-29 09:51:28 CET' } end @@ -62,5 +66,14 @@ FactoryBot.define do trait :strategy_depend do options { { trigger: { strategy: 'depend' } } } end + + trait :manual do + status { 'manual' } + self.when { 'manual' } + end + + trait :playable do + manual + end end end diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index 865ae3ad8cb..e387ea4d473 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -113,7 +113,7 @@ RSpec.describe "Compare", :js do click_button('Compare') - page.within('.alert') do + page.within('.gl-alert') do expect(page).to have_text("Too many changes to show. To preserve performance only 3 of 3+ files are displayed.") end end diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index 2f174c45ad7..35cf3a62fff 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -1,7 +1,6 @@ -import MockAdapter from 'axios-mock-adapter'; import { trimText } from 'helpers/text_helper'; -import axios from '~/lib/utils/axios_utils'; -import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji'; +import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji'; +import { glEmojiTag, searchEmoji } from '~/emoji'; import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -30,54 +29,11 @@ const emptySupportMap = { 1.1: false, }; -const emojiFixtureMap = { - atom: { - name: 'atom', - moji: '⚛', - description: 'atom symbol', - unicodeVersion: '4.1', - }, - bomb: { - name: 'bomb', - moji: '💣', - unicodeVersion: '6.0', - description: 'bomb', - }, - construction_worker_tone5: { - name: 'construction_worker_tone5', - moji: '👷🏿', - unicodeVersion: '8.0', - description: 'construction worker tone 5', - }, - five: { - name: 'five', - moji: '5️⃣', - unicodeVersion: '3.0', - description: 'keycap digit five', - }, - grey_question: { - name: 'grey_question', - moji: '❔', - unicodeVersion: '6.0', - description: 'white question mark ornament', - }, -}; - describe('gl_emoji', () => { let mock; - beforeEach(() => { - const emojiData = Object.fromEntries( - Object.values(emojiFixtureMap).map(m => { - const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m; - return [n, { c, e, d, u }]; - }), - ); - - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData)); - - return initEmojiMap().catch(() => {}); + beforeEach(async () => { + mock = await initEmojiMock(); }); afterEach(() => { @@ -398,21 +354,101 @@ describe('gl_emoji', () => { describe('searchEmoji', () => { const { atom, grey_question } = emojiFixtureMap; - const contains = (e, term) => - expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name); + const search = (query, opts) => searchEmoji(query, opts).map(({ name }) => name); + const mangle = str => str.slice(0, 1) + str.slice(-1); + const partial = str => str.slice(0, 2); + + describe('with default options', () => { + const subject = query => search(query); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should not match by partial: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).not.toContain(atom.name); + }); + }); + + it(`should match by unicode value: ${atom.moji}`, () => { + expect(subject(atom.moji)).toContain(atom.name); + }); + + it('should not return a fallback value', () => { + expect(subject('foo bar baz')).toHaveLength(0); + }); + }); + + describe('with fuzzy match', () => { + const subject = query => search(query, { match: 'fuzzy' }); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should match by partial: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).toContain(atom.name); + }); + }); + }); + + describe('with contains match', () => { + const subject = query => search(query, { match: 'contains' }); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should match by partial: ${partial(accessor(atom))}`, () => { + expect(subject(partial(accessor(atom)))).toContain(atom.name); + }); + + it(`should not match by mangled: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).not.toContain(atom.name); + }); + }); + }); - it('should match by full name', () => contains(grey_question, 'grey_question')); - it('should match by full alias', () => contains(atom, 'atom_symbol')); - it('should match by full description', () => contains(grey_question, 'ornament')); + describe('with fallback', () => { + const subject = query => search(query, { fallback: true }); - it('should match by partial name', () => contains(grey_question, 'question')); - it('should match by partial alias', () => contains(atom, '_symbol')); - it('should match by partial description', () => contains(grey_question, 'ment')); + it('should return a fallback value', () => + expect(subject('foo bar baz')).toContain(grey_question.name)); + }); + + describe('with name and alias fields', () => { + const subject = query => search(query, { fields: ['name', 'alias'] }); - it('should fuzzy match by name', () => contains(grey_question, 'greion')); - it('should fuzzy match by alias', () => contains(atom, 'atobol')); - it('should fuzzy match by description', () => contains(grey_question, 'ornt')); + it(`should match by name: ${atom.name}`, () => { + expect(subject(atom.name)).toContain(atom.name); + }); + + it(`should match by alias: ${atom.aliases[0]}`, () => { + expect(subject(atom.aliases[0])).toContain(atom.name); + }); - it('should match by character', () => contains(grey_question, '❔')); + it(`should not match by description: ${atom.description}`, () => { + expect(subject(atom.description)).not.toContain(atom.name); + }); + + it(`should not match by unicode value: ${atom.moji}`, () => { + expect(subject(atom.moji)).not.toContain(atom.name); + }); + }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 25cd606a094..ce59574ef61 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -1,6 +1,7 @@ /* eslint no-param-reassign: "off" */ import $ from 'jquery'; +import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; @@ -702,4 +703,62 @@ describe('GfmAutoComplete', () => { `('$input shows $output.length labels', expectLabels); }); }); + + describe('emoji', () => { + const { atom } = emojiFixtureMap; + const assertInserted = ({ input, subject, emoji }) => + expect(subject).toBe(`:${emoji?.name || input}:`); + const assertTemplated = ({ input, subject, emoji }) => + expect(subject.replace(/\s+/g, ' ')).toBe( + `<li>${input} <gl-emoji data-name="${emoji?.name || input}"></gl-emoji> </li>`, + ); + + let mock; + + beforeEach(async () => { + mock = await initEmojiMock(); + + await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':'); + if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe.each` + name | inputFormat | assert + ${'insertTemplateFunction'} | ${name => ({ name })} | ${assertInserted} + ${'templateFunction'} | ${name => name} | ${assertTemplated} + `('Emoji.$name', ({ name, inputFormat, assert }) => { + const execute = (input, emoji) => + assert({ + input, + emoji, + subject: GfmAutoComplete.Emoji[name](inputFormat(input)), + }); + + describeEmojiFields('for $field', ({ accessor }) => { + it('should work with lowercase', () => { + execute(accessor(atom), atom); + }); + + it('should work with uppercase', () => { + execute(accessor(atom).toUpperCase(), atom); + }); + + it('should work with partial value', () => { + execute(accessor(atom).slice(1), atom); + }); + }); + + it('should work with unicode value', () => { + execute(atom.moji, atom); + }); + + it('should pass through unknown value', () => { + execute('foo bar baz'); + }); + }); + }); }); diff --git a/spec/frontend/helpers/emoji.js b/spec/frontend/helpers/emoji.js new file mode 100644 index 00000000000..0f257c32a68 --- /dev/null +++ b/spec/frontend/helpers/emoji.js @@ -0,0 +1,66 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; + +export const emojiFixtureMap = { + atom: { + name: 'atom', + moji: '⚛', + description: 'atom symbol', + unicodeVersion: '4.1', + aliases: ['atom_symbol'], + }, + bomb: { + name: 'bomb', + moji: '💣', + unicodeVersion: '6.0', + description: 'bomb', + aliases: [], + }, + construction_worker_tone5: { + name: 'construction_worker_tone5', + moji: '👷🏿', + unicodeVersion: '8.0', + description: 'construction worker tone 5', + aliases: [], + }, + five: { + name: 'five', + moji: '5️⃣', + unicodeVersion: '3.0', + description: 'keycap digit five', + aliases: [], + }, + grey_question: { + name: 'grey_question', + moji: '❔', + unicodeVersion: '6.0', + description: 'white question mark ornament', + aliases: [], + }, +}; + +export async function initEmojiMock() { + const emojiData = Object.fromEntries( + Object.values(emojiFixtureMap).map(m => { + const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m; + return [n, { c, e, d, u }]; + }), + ); + + const mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData)); + + await initEmojiMap(); + + return mock; +} + +export function describeEmojiFields(label, tests) { + describe.each` + field | accessor + ${'name'} | ${e => e.name} + ${'alias'} | ${e => e.aliases[0]} + ${'description'} | ${e => e.description} + `(label, tests); +} diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 2f8f1092612..f600f2bcd55 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -1,5 +1,6 @@ import { formatRelevantDigits, + bytesToKB, bytesToKiB, bytesToMiB, bytesToGiB, @@ -54,6 +55,16 @@ describe('Number Utils', () => { }); }); + describe('bytesToKB', () => { + it.each` + input | output + ${1000} | ${1} + ${1024} | ${1.024} + `('returns $output KB for $input bytes', ({ input, output }) => { + expect(bytesToKB(input)).toBe(output); + }); + }); + describe('bytesToKiB', () => { it('calculates KiB for the given bytes', () => { expect(bytesToKiB(1024)).toEqual(1); diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb index 2ea215450cb..e73665a1b1d 100644 --- a/spec/graphql/types/snippet_type_spec.rb +++ b/spec/graphql/types/snippet_type_spec.rb @@ -124,7 +124,7 @@ RSpec.describe GitlabSchema.types['Snippet'] do end describe '#blob' do - let(:query_blob) { subject.dig('data', 'snippets', 'edges')[0]['node']['blob'] } + let(:query_blob) { subject.dig('data', 'snippets', 'nodes')[0]['blob'] } subject { GitlabSchema.execute(snippet_query_for(field: 'blob'), context: { current_user: user }).as_json } @@ -151,21 +151,17 @@ RSpec.describe GitlabSchema.types['Snippet'] do describe '#blobs' do let_it_be(:snippet) { create(:personal_snippet, :public, author: user) } - let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') } + let(:query_blobs) { subject.dig('data', 'snippets', 'nodes')[0].dig('blobs', 'nodes') } let(:paths) { [] } let(:query) do %( { snippets { - edges { - node { - blobs(paths: #{paths}) { - edges { - node { - name - path - } - } + nodes { + blobs(paths: #{paths}) { + nodes { + name + path } } } @@ -188,8 +184,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do it_behaves_like 'an array' it 'contains the first blob from the snippet' do - expect(query_blobs.first['node']['name']).to eq blob.name - expect(query_blobs.first['node']['path']).to eq blob.path + expect(query_blobs.first['name']).to eq blob.name + expect(query_blobs.first['path']).to eq blob.path end end @@ -200,7 +196,7 @@ RSpec.describe GitlabSchema.types['Snippet'] do it_behaves_like 'an array' it 'contains all the blobs from the repository' do - resulting_blobs_names = query_blobs.map { |b| b['node']['name'] } + resulting_blobs_names = query_blobs.map { |b| b['name'] } expect(resulting_blobs_names).to match_array(blobs.map(&:name)) end @@ -211,7 +207,7 @@ RSpec.describe GitlabSchema.types['Snippet'] do it_behaves_like 'an array' it 'returns specific files' do - resulting_blobs_names = query_blobs.map { |b| b['node']['name'] } + resulting_blobs_names = query_blobs.map { |b| b['name'] } expect(resulting_blobs_names).to match(paths) end @@ -223,12 +219,10 @@ RSpec.describe GitlabSchema.types['Snippet'] do %( { snippets { - edges { - node { - #{field} { - name - path - } + nodes { + #{field} { + name + path } } } diff --git a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb index f33176c3da3..8b2e0410474 100644 --- a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb @@ -228,4 +228,66 @@ RSpec.describe Gitlab::Ci::Config::Entry::Bridge do end end end + + describe '#manual_action?' do + context 'when job is a manual action' do + let(:config) { { script: 'deploy', when: 'manual' } } + + it { is_expected.to be_manual_action } + end + + context 'when job is not a manual action' do + let(:config) { { script: 'deploy' } } + + it { is_expected.not_to be_manual_action } + end + end + + describe '#ignored?' do + context 'when job is a manual action' do + context 'when it is not specified if job is allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual' } + end + + it { is_expected.to be_ignored } + end + + context 'when job is allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual', allow_failure: true } + end + + it { is_expected.to be_ignored } + end + + context 'when job is not allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual', allow_failure: false } + end + + it { is_expected.not_to be_ignored } + end + end + + context 'when job is not a manual action' do + context 'when it is not specified if job is allowed to fail' do + let(:config) { { script: 'deploy' } } + + it { is_expected.not_to be_ignored } + end + + context 'when job is allowed to fail' do + let(:config) { { script: 'deploy', allow_failure: true } } + + it { is_expected.to be_ignored } + end + + context 'when job is not allowed to fail' do + let(:config) { { script: 'deploy', allow_failure: false } } + + it { is_expected.not_to be_ignored } + end + end + end end diff --git a/spec/lib/gitlab/ci/status/bridge/factory_spec.rb b/spec/lib/gitlab/ci/status/bridge/factory_spec.rb index 021b777a0ff..d27bb98ba9a 100644 --- a/spec/lib/gitlab/ci/status/bridge/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/bridge/factory_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Factory do end context 'when bridge is created' do - let(:bridge) { create(:ci_bridge) } + let(:bridge) { create_bridge(:created) } it 'matches correct core status' do expect(factory.core_status).to be_a Gitlab::Ci::Status::Created @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Factory do end context 'when bridge is failed' do - let(:bridge) { create(:ci_bridge, :failed) } + let(:bridge) { create_bridge(:failed) } it 'matches correct core status' do expect(factory.core_status).to be_a Gitlab::Ci::Status::Failed @@ -70,4 +70,61 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Factory do end end end + + context 'when bridge is a manual action' do + let(:bridge) { create_bridge(:playable) } + + it 'matches correct core status' do + expect(factory.core_status).to be_a Gitlab::Ci::Status::Manual + end + + it 'matches correct extended statuses' do + expect(factory.extended_statuses) + .to eq [Gitlab::Ci::Status::Bridge::Manual, + Gitlab::Ci::Status::Bridge::Play, + Gitlab::Ci::Status::Bridge::Action] + end + + it 'fabricates action detailed status' do + expect(status).to be_a Gitlab::Ci::Status::Bridge::Action + end + + it 'fabricates status with correct details' do + expect(status.text).to eq s_('CiStatusText|manual') + expect(status.group).to eq 'manual' + expect(status.icon).to eq 'status_manual' + expect(status.favicon).to eq 'favicon_status_manual' + expect(status.illustration).to include(:image, :size, :title, :content) + expect(status.label).to include 'manual play action' + expect(status).not_to have_details + expect(status.action_path).to include 'play' + end + + context 'when user has ability to play action' do + before do + bridge.downstream_project.add_developer(user) + end + + it 'fabricates status that has action' do + expect(status).to have_action + end + end + + context 'when user does not have ability to play action' do + it 'fabricates status that has no action' do + expect(status).not_to have_action + end + end + end + + private + + def create_bridge(trait) + upstream_project = create(:project, :repository) + downstream_project = create(:project, :repository) + upstream_pipeline = create(:ci_pipeline, :running, project: upstream_project) + trigger = { trigger: { project: downstream_project.full_path, branch: 'feature' } } + + create(:ci_bridge, trait, options: trigger, pipeline: upstream_pipeline) + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 2df15c8f400..edb9a613f85 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -413,6 +413,7 @@ project: - stages - ci_refs - builds +- processables - runner_projects - runners - variables diff --git a/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb b/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb new file mode 100644 index 00000000000..1a618712b32 --- /dev/null +++ b/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200929052138_create_initial_versions_for_pre_versioning_terraform_states.rb') + +RSpec.describe CreateInitialVersionsForPreVersioningTerraformStates do + let(:namespace) { table(:namespaces).create!(name: 'terraform', path: 'terraform') } + let(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } + let(:terraform_state_versions) { table(:terraform_state_versions) } + + def create_state!(project, versioning_enabled:) + table(:terraform_states).create!( + project_id: project.id, + uuid: 'uuid', + file_store: 2, + file: 'state.tfstate', + versioning_enabled: versioning_enabled + ) + end + + describe '#up' do + context 'for a state that is already versioned' do + let!(:terraform_state) { create_state!(project, versioning_enabled: true) } + + it 'does not insert a version record' do + expect { migrate! }.not_to change { terraform_state_versions.count } + end + end + + context 'for a state that is not yet versioned' do + let!(:terraform_state) { create_state!(project, versioning_enabled: false) } + + it 'creates a version using the current state data' do + expect { migrate! }.to change { terraform_state_versions.count }.by(1) + + migrated_version = terraform_state_versions.last + expect(migrated_version.terraform_state_id).to eq(terraform_state.id) + expect(migrated_version.version).to be_zero + expect(migrated_version.file_store).to eq(terraform_state.file_store) + expect(migrated_version.file).to eq(terraform_state.file) + expect(migrated_version.created_at).to be_present + expect(migrated_version.updated_at).to be_present + end + end + end +end diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 850fc1ec6e6..c464e176c17 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -59,30 +59,20 @@ RSpec.describe Ci::Bridge do describe 'state machine transitions' do context 'when bridge points towards downstream' do - it 'schedules downstream pipeline creation' do - expect(bridge).to receive(:schedule_downstream_pipeline!) + %i[created manual].each do |status| + it "schedules downstream pipeline creation when the status is #{status}" do + bridge.status = status - bridge.enqueue! - end - end - end - - describe 'state machine transitions' do - context 'when bridge points towards downstream' do - it 'schedules downstream pipeline creation' do - expect(bridge).to receive(:schedule_downstream_pipeline!) + expect(bridge).to receive(:schedule_downstream_pipeline!) - bridge.enqueue! + bridge.enqueue! + end end - end - end - describe 'state machine transitions' do - context 'when bridge points towards downstream' do - it 'schedules downstream pipeline creation' do - expect(bridge).to receive(:schedule_downstream_pipeline!) + it 'raises error when the status is failed' do + bridge.status = :failed - bridge.enqueue! + expect { bridge.enqueue! }.to raise_error(StateMachines::InvalidTransition) end end end @@ -304,4 +294,67 @@ RSpec.describe Ci::Bridge do end end end + + describe '#play' do + let(:downstream_project) { create(:project) } + let(:user) { create(:user) } + let(:bridge) { create(:ci_bridge, :playable, pipeline: pipeline, downstream: downstream_project) } + + subject { bridge.play(user) } + + before do + project.add_maintainer(user) + downstream_project.add_maintainer(user) + end + + it 'enqueues the bridge' do + subject + + expect(bridge).to be_pending + end + end + + describe '#playable?' do + context 'when bridge is a manual action' do + subject { build_stubbed(:ci_bridge, :manual).playable? } + + it { is_expected.to be_truthy } + + context 'when FF ci_manual_bridges is disabled' do + before do + stub_feature_flags(ci_manual_bridges: false) + end + + it { is_expected.to be_falsey } + end + end + + context 'when build is not a manual action' do + subject { build_stubbed(:ci_bridge, :created).playable? } + + it { is_expected.to be_falsey } + end + end + + describe '#action?' do + context 'when bridge is a manual action' do + subject { build_stubbed(:ci_bridge, :manual).action? } + + it { is_expected.to be_truthy } + + context 'when FF ci_manual_bridges is disabled' do + before do + stub_feature_flags(ci_manual_bridges: false) + end + + it { is_expected.to be_falsey } + end + end + + context 'when build is not a manual action' do + subject { build_stubbed(:ci_bridge, :created).action? } + + it { is_expected.to be_falsey } + end + end end diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb index 1d99d103bb8..608c5bdf03a 100644 --- a/spec/models/terraform/state_spec.rb +++ b/spec/models/terraform/state_spec.rb @@ -87,11 +87,17 @@ RSpec.describe Terraform::State do let(:terraform_state) { create(:terraform_state, :with_file) } it { is_expected.to eq terraform_state.file } + + context 'and a version exists (migration to versioned in progress)' do + let!(:migrated_version) { create(:terraform_state_version, terraform_state: terraform_state) } + + it { is_expected.to eq terraform_state.latest_version.file } + end end end describe '#update_file!' do - let(:version) { 2 } + let(:version) { 3 } let(:data) { Hash[terraform_version: '0.12.21'].to_json } subject { terraform_state.update_file!(CarrierWaveStringFile.new(data), version: version) } @@ -115,6 +121,33 @@ RSpec.describe Terraform::State do expect(terraform_state.latest_file.read).to eq(data) end + + context 'and a version exists (migration to versioned in progress)' do + let!(:migrated_version) { create(:terraform_state_version, terraform_state: terraform_state, version: 0) } + + it 'creates a new version, corrects the migrated version number, and marks the state as versioned' do + expect { subject }.to change { Terraform::StateVersion.count } + + expect(migrated_version.reload.version).to eq(1) + expect(migrated_version.file.read).to eq(terraform_state_file) + + expect(terraform_state.reload.latest_version.version).to eq(version) + expect(terraform_state.latest_version.file.read).to eq(data) + expect(terraform_state).to be_versioning_enabled + end + + context 'the current version cannot be determined' do + before do + migrated_version.update!(file: CarrierWaveStringFile.new('invalid-json')) + end + + it 'uses version - 1 to correct the migrated version number' do + expect { subject }.to change { Terraform::StateVersion.count } + + expect(migrated_version.reload.version).to eq(2) + end + end + end end end end diff --git a/spec/policies/ci/bridge_policy_spec.rb b/spec/policies/ci/bridge_policy_spec.rb new file mode 100644 index 00000000000..e598e2f7626 --- /dev/null +++ b/spec/policies/ci/bridge_policy_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::BridgePolicy do + let_it_be(:user, reload: true) { create(:user) } + let_it_be(:project, reload: true) { create(:project) } + let_it_be(:downstream_project, reload: true) { create(:project, :repository) } + let_it_be(:pipeline, reload: true) { create(:ci_empty_pipeline, project: project) } + let_it_be(:bridge, reload: true) { create(:ci_bridge, pipeline: pipeline, downstream: downstream_project) } + + let(:policy) do + described_class.new(user, bridge) + end + + describe '#play_job' do + before do + fake_access = double('Gitlab::UserAccess') + expect(fake_access).to receive(:can_update_branch?).with('master').and_return(can_update_branch) + expect(Gitlab::UserAccess).to receive(:new).with(user, container: downstream_project).and_return(fake_access) + end + + context 'when user can update the downstream branch' do + let(:can_update_branch) { true } + + it 'allows' do + expect(policy).to be_allowed :play_job + end + end + + context 'when user can not update the downstream branch' do + let(:can_update_branch) { false } + + it 'does not allow' do + expect(policy).not_to be_allowed :play_job + end + end + end +end diff --git a/spec/services/ci/play_bridge_service_spec.rb b/spec/services/ci/play_bridge_service_spec.rb new file mode 100644 index 00000000000..0482ad4d76f --- /dev/null +++ b/spec/services/ci/play_bridge_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::PlayBridgeService, '#execute' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:downstream_project) { create(:project) } + let(:bridge) { create(:ci_bridge, :playable, pipeline: pipeline, downstream: downstream_project) } + let(:instance) { described_class.new(project, user) } + + subject(:execute_service) { instance.execute(bridge) } + + context 'when user can run the bridge' do + before do + allow(instance).to receive(:can?).with(user, :play_job, bridge).and_return(true) + end + + it 'marks the bridge pending' do + execute_service + + expect(bridge.reload).to be_pending + end + + it 'enqueues Ci::CreateCrossProjectPipelineWorker' do + expect(::Ci::CreateCrossProjectPipelineWorker).to receive(:perform_async).with(bridge.id) + + execute_service + end + + it "updates bridge's user" do + execute_service + + expect(bridge.reload.user).to eq(user) + end + + context 'when bridge is not playable' do + let(:bridge) { create(:ci_bridge, :failed, pipeline: pipeline, downstream: downstream_project) } + + it 'raises StateMachines::InvalidTransition' do + expect { execute_service }.to raise_error StateMachines::InvalidTransition + end + end + end + + context 'when user can not run the bridge' do + before do + allow(instance).to receive(:can?).with(user, :play_job, bridge).and_return(false) + end + + it 'allows user with developer role to play a bridge' do + expect { execute_service }.to raise_error Gitlab::Access::AccessDeniedError + end + end +end diff --git a/spec/services/ci/play_manual_stage_service_spec.rb b/spec/services/ci/play_manual_stage_service_spec.rb index e30ec8bfda5..3e2a95ee975 100644 --- a/spec/services/ci/play_manual_stage_service_spec.rb +++ b/spec/services/ci/play_manual_stage_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Ci::PlayManualStageService, '#execute' do let(:current_user) { create(:user) } let(:pipeline) { create(:ci_pipeline, user: current_user) } let(:project) { pipeline.project } + let(:downstream_project) { create(:project) } let(:service) { described_class.new(project, current_user, pipeline: pipeline) } let(:stage_status) { 'manual' } @@ -18,40 +19,42 @@ RSpec.describe Ci::PlayManualStageService, '#execute' do before do project.add_maintainer(current_user) + downstream_project.add_maintainer(current_user) create_builds_for_stage(status: stage_status) + create_bridge_for_stage(status: stage_status) end - context 'when pipeline has manual builds' do + context 'when pipeline has manual processables' do before do service.execute(stage) end - it 'starts manual builds from pipeline' do - expect(pipeline.builds.manual.count).to eq(0) + it 'starts manual processables from pipeline' do + expect(pipeline.processables.manual.count).to eq(0) end - it 'updates manual builds' do - pipeline.builds.each do |build| - expect(build.user).to eq(current_user) + it 'updates manual processables' do + pipeline.processables.each do |processable| + expect(processable.user).to eq(current_user) end end end - context 'when pipeline has no manual builds' do + context 'when pipeline has no manual processables' do let(:stage_status) { 'failed' } before do service.execute(stage) end - it 'does not update the builds' do - expect(pipeline.builds.failed.count).to eq(3) + it 'does not update the processables' do + expect(pipeline.processables.failed.count).to eq(4) end end - context 'when user does not have permission on a specific build' do + context 'when user does not have permission on a specific processable' do before do - allow_next_instance_of(Ci::Build) do |instance| + allow_next_instance_of(Ci::Processable) do |instance| allow(instance).to receive(:play).and_raise(Gitlab::Access::AccessDeniedError) end @@ -60,12 +63,14 @@ RSpec.describe Ci::PlayManualStageService, '#execute' do it 'logs the error' do expect(Gitlab::AppLogger).to receive(:error) - .exactly(stage.builds.manual.count) + .exactly(stage.processables.manual.count) service.execute(stage) end end + private + def create_builds_for_stage(options) options.merge!({ when: 'manual', @@ -77,4 +82,17 @@ RSpec.describe Ci::PlayManualStageService, '#execute' do create_list(:ci_build, 3, options) end + + def create_bridge_for_stage(options) + options.merge!({ + when: 'manual', + pipeline: pipeline, + stage: stage.name, + stage_id: stage.id, + user: pipeline.user, + downstream: downstream_project + }) + + create(:ci_bridge, options) + end end diff --git a/spec/uploaders/terraform/versioned_state_uploader_spec.rb b/spec/uploaders/terraform/versioned_state_uploader_spec.rb index ecc3f943480..eeb54cb61c7 100644 --- a/spec/uploaders/terraform/versioned_state_uploader_spec.rb +++ b/spec/uploaders/terraform/versioned_state_uploader_spec.rb @@ -12,9 +12,18 @@ RSpec.describe Terraform::VersionedStateUploader do end describe '#filename' do - it 'contains the UUID of the terraform state record' do + it 'contains the version of the terraform state record' do expect(subject.filename).to eq("#{model.version}.tfstate") end + + context 'legacy state with versioning disabled' do + let(:state) { create(:legacy_terraform_state) } + let(:model) { create(:terraform_state_version, terraform_state: state) } + + it 'contains the UUID of the terraform state record' do + expect(subject.filename).to eq("#{model.uuid}.tfstate") + end + end end describe '#store_dir' do @@ -25,5 +34,14 @@ RSpec.describe Terraform::VersionedStateUploader do expect(subject.store_dir).to eq(:store_dir) end + + context 'legacy state with versioning disabled' do + let(:state) { create(:legacy_terraform_state) } + let(:model) { create(:terraform_state_version, terraform_state: state) } + + it 'contains the ID of the project' do + expect(subject.store_dir).to include(model.project_id.to_s) + end + end end end |