diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-20 15:52:10 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-20 15:52:10 +0300 |
commit | dba864470fbcbb6bdd5b94eb510acdce62c962d8 (patch) | |
tree | e8ead0b84e7b814f5891d2c8cd3db2d6b635fb64 /app | |
parent | b7d29500f28ff59c8898cdf889a40d3da908f162 (diff) |
Add latest changes from gitlab-org/gitlab@12-8-stable-ee
Diffstat (limited to 'app')
1124 files changed, 18328 insertions, 6892 deletions
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue new file mode 100644 index 00000000000..5e16f6f3873 --- /dev/null +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -0,0 +1,168 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui'; +import _ from 'underscore'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import axios from '~/lib/utils/axios_utils'; +import { s__, __, sprintf } from '~/locale'; +import createFlash from '~/flash'; + +export default { + COPY_TO_CLIPBOARD: __('Copy'), + RESET_KEY: __('Reset key'), + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlModal, + ClipboardButton, + ToggleButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + props: { + initialAuthorizationKey: { + type: String, + required: false, + default: '', + }, + formPath: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + learnMoreUrl: { + type: String, + required: false, + default: '', + }, + initialActivated: { + type: Boolean, + required: true, + }, + }, + data() { + return { + activated: this.initialActivated, + loadingActivated: false, + authorizationKey: this.initialAuthorizationKey, + }; + }, + computed: { + learnMoreDescription() { + return sprintf( + s__( + 'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.', + ), + { + linkStart: `<a href="${_.escape( + this.learnMoreUrl, + )}" target="_blank" rel="noopener noreferrer">`, + linkEnd: '</a>', + }, + false, + ); + }, + sectionDescription() { + const desc = s__( + 'AlertService|Each alert source must be authorized using the following URL and authorization key.', + ); + const learnMoreDesc = this.learnMoreDescription ? ` ${this.learnMoreDescription}` : ''; + + return `${desc}${learnMoreDesc}`; + }, + }, + watch: { + activated() { + this.updateIcon(); + }, + }, + methods: { + updateIcon() { + return document.querySelectorAll('.js-service-active-status').forEach(icon => { + if (icon.dataset.value === this.activated.toString()) { + icon.classList.remove('d-none'); + } else { + icon.classList.add('d-none'); + } + }); + }, + resetKey() { + return axios + .put(this.formPath, { service: { token: '' } }) + .then(res => { + this.authorizationKey = res.data.token; + }) + .catch(() => { + createFlash(__('Failed to reset key. Please try again.')); + }); + }, + toggleActivated(value) { + this.loadingActivated = true; + return axios + .put(this.formPath, { service: { active: value } }) + .then(() => { + this.activated = value; + this.loadingActivated = false; + }) + .catch(() => { + createFlash(__('Update failed. Please try again.')); + this.loadingActivated = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <p v-html="sectionDescription"></p> + <gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold"> + <toggle-button + id="activated" + :disabled-input="loadingActivated" + :is-loading="loadingActivated" + :value="activated" + @change="toggleActivated" + /> + </gl-form-group> + <gl-form-group :label="__('URL')" label-for="url" label-class="label-bold"> + <div class="input-group"> + <gl-form-input id="url" :readonly="true" :value="url" /> + <span class="input-group-append"> + <clipboard-button :text="url" :title="$options.COPY_TO_CLIPBOARD" /> + </span> + </div> + </gl-form-group> + <gl-form-group + :label="__('Authorization key')" + label-for="authorization-key" + label-class="label-bold" + > + <div class="input-group"> + <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> + <span class="input-group-append"> + <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" /> + </span> + </div> + <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button> + <gl-modal + modal-id="authKeyModal" + :title="$options.RESET_KEY" + :ok-title="$options.RESET_KEY" + ok-variant="danger" + @ok="resetKey" + > + {{ + __( + 'Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ) + }} + </gl-modal> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js new file mode 100644 index 00000000000..d49725c6a4d --- /dev/null +++ b/app/assets/javascripts/alerts_service_settings/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import AlertsServiceForm from './components/alerts_service_form.vue'; + +export default el => { + if (!el) { + return null; + } + + const { activated: activatedStr, formPath, authorizationKey, url, learnMoreUrl } = el.dataset; + const activated = parseBoolean(activatedStr); + + return new Vue({ + el, + render(createElement) { + return createElement(AlertsServiceForm, { + props: { + initialActivated: activated, + formPath, + learnMoreUrl, + initialAuthorizationKey: authorizationKey, + url, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index bee079c6643..4dc4ce543e9 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -24,6 +24,7 @@ const Api = { projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectRunnersPath: '/api/:version/projects/:id/runners', + projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', @@ -44,6 +45,8 @@ const Api = { releasePath: '/api/:version/projects/:id/releases/:tag_name', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', + pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', + lsifPath: '/api/:version/projects/:id/commits/:commit_id/lsif/info', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -218,6 +221,22 @@ const Api = { return axios.get(url, config); }, + projectProtectedBranches(id, query = '') { + const url = Api.buildUrl(Api.projectProtectedBranchesPath).replace( + ':id', + encodeURIComponent(id), + ); + + return axios + .get(url, { + params: { + search: query, + per_page: DEFAULT_PER_PAGE, + }, + }) + .then(({ data }) => data); + }, + mergeRequests(params = {}) { const url = Api.buildUrl(Api.mergeRequestsPath); @@ -448,6 +467,22 @@ const Api = { return axios.get(url); }, + pipelineSingle(id, pipelineId) { + const url = Api.buildUrl(this.pipelineSinglePath) + .replace(':id', encodeURIComponent(id)) + .replace(':pipeline_id', encodeURIComponent(pipelineId)); + + return axios.get(url); + }, + + lsifData(projectPath, commitId, path) { + const url = Api.buildUrl(this.lsifPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':commit_id', commitId); + + return axios.get(url, { params: { path } }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/behaviors/markdown/constants.js b/app/assets/javascripts/behaviors/markdown/constants.js new file mode 100644 index 00000000000..b4545d6c6c6 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/constants.js @@ -0,0 +1,3 @@ +// https://prosemirror.net/docs/ref/#model.ParseRule.priority +export const DEFAULT_PARSE_RULE_PRIORITY = 50; +export const HIGHER_PARSE_RULE_PRIORITY = 1 + DEFAULT_PARSE_RULE_PRIORITY; diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js index ebed8698e21..7e020139fe7 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Mark } from 'tiptap'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class InlineHTML extends Mark { @@ -35,7 +35,7 @@ export default class InlineHTML extends Mark { mixable: true, open(state, mark) { return `<${mark.attrs.tag}${ - mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : '' + mark.attrs.title ? ` title="${state.esc(esc(mark.attrs.title))}"` : '' }>`; }, close(state, mark) { diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js index e582fb18f15..04441d5d710 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/math.js +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -2,6 +2,7 @@ import { Mark } from 'tiptap'; import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::MathFilter export default class MathMark extends Mark { @@ -15,7 +16,7 @@ export default class MathMark extends Mark { // Matches HTML generated by Banzai::Filter::MathFilter { tag: 'code.code.math[data-math-style=inline]', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, }, // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js index 48ac408cf24..146349b118c 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js @@ -1,53 +1,9 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import Playable from './playable'; // Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter -export default class Audio extends Node { - get name() { - return 'audio'; - } - - get schema() { - return { - attrs: { - src: {}, - alt: { - default: null, - }, - }, - group: 'block', - draggable: true, - parseDOM: [ - { - tag: '.audio-container', - skip: true, - }, - { - tag: '.audio-container p', - priority: 51, - ignore: true, - }, - { - tag: 'audio[src]', - getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), - }, - ], - toDOM: node => [ - 'audio', - { - src: node.attrs.src, - controls: true, - 'data-setup': '{}', - 'data-title': node.attrs.alt, - }, - ], - }; - } - - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.image(state, node); - state.closeBlock(node); +export default class Audio extends Playable { + constructor() { + super(); + this.mediaType = 'audio'; } } diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js index e839396330e..b1983eebe15 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -3,6 +3,7 @@ import { Image as BaseImage } from 'tiptap-extensions'; import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { placeholderImage } from '~/lazy_loader'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; export default class Image extends BaseImage { get schema() { @@ -23,7 +24,7 @@ export default class Image extends BaseImage { // Matches HTML generated by Banzai::Filter::ImageLinkFilter { tag: 'a.no-attachment-icon', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, skip: true, }, // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js index 25c4976a1bc..a28d7be3758 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Node } from 'tiptap'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter export default class OrderedTaskList extends Node { @@ -14,7 +15,7 @@ export default class OrderedTaskList extends Node { content: '(task_list_item|list_item)+', parseDOM: [ { - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, tag: 'ol.task-list', }, ], diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js new file mode 100644 index 00000000000..9209c69d04a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js @@ -0,0 +1,73 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable @gitlab/i18n/no-non-i18n-strings */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; + +/** + * Abstract base class for playable media, like video and audio. + * Must not be instantiated directly. Subclasses must set + * the `mediaType` property in their constructors. + * @abstract + */ +export default class Playable extends Node { + constructor() { + super(); + this.mediaType = ''; + this.extraElementAttrs = {}; + } + + get name() { + return this.mediaType; + } + + get schema() { + const attrs = { + src: {}, + alt: { + default: null, + }, + }; + + const parseDOM = [ + { + tag: `.${this.mediaType}-container`, + skip: true, + }, + { + tag: `.${this.mediaType}-container p`, + priority: HIGHER_PARSE_RULE_PRIORITY, + ignore: true, + }, + { + tag: `${this.mediaType}[src]`, + getAttrs: el => ({ src: el.src, alt: el.dataset.title }), + }, + ]; + + const toDOM = node => [ + this.mediaType, + { + src: node.attrs.src, + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + ...this.extraElementAttrs, + }, + ]; + + return { + attrs, + group: 'block', + draggable: true, + parseDOM, + toDOM, + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js index 5d6bbeca833..aa724798da6 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Node } from 'tiptap'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses export default class Reference extends Node { @@ -23,7 +24,7 @@ export default class Reference extends Node { parseDOM: [ { tag: 'a.gfm:not([data-link=true])', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, getAttrs: el => ({ className: el.className, referenceType: el.dataset.referenceType, diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js index e7eee636402..6e3c16f0a08 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import TableRow from './table_row'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; const CENTER_ALIGN = 'center'; @@ -16,7 +17,7 @@ export default class TableHeaderRow extends TableRow { parseDOM: [ { tag: 'thead tr', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, }, ], toDOM: () => ['tr', 0], diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js index 9a2e2c03213..db9072acc3a 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -2,6 +2,7 @@ import { Node } from 'tiptap'; import { __ } from '~/locale'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter export default class TableOfContents extends Node { @@ -16,11 +17,11 @@ export default class TableOfContents extends Node { parseDOM: [ { tag: 'ul.section-nav', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, }, { tag: 'p.table-of-contents', - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, }, ], toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')], diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js index ab33bc21502..35ba2eb0674 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Node } from 'tiptap'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter export default class TaskList extends Node { @@ -14,7 +15,7 @@ export default class TaskList extends Node { content: '(task_list_item|list_item)+', parseDOM: [ { - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, tag: 'ul.task-list', }, ], diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js index d0ee7333d5e..7bb56b4c406 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Node } from 'tiptap'; +import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter export default class TaskListItem extends Node { @@ -20,7 +21,7 @@ export default class TaskListItem extends Node { content: 'paragraph block*', parseDOM: [ { - priority: 51, + priority: HIGHER_PARSE_RULE_PRIORITY, tag: 'li.task-list-item', getAttrs: el => { const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js index 516f983397d..68085c2c416 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/video.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js @@ -1,54 +1,10 @@ -/* eslint-disable class-methods-use-this */ - -import { Node } from 'tiptap'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import Playable from './playable'; // Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter -export default class Video extends Node { - get name() { - return 'video'; - } - - get schema() { - return { - attrs: { - src: {}, - alt: { - default: null, - }, - }, - group: 'block', - draggable: true, - parseDOM: [ - { - tag: '.video-container', - skip: true, - }, - { - tag: '.video-container p', - priority: 51, - ignore: true, - }, - { - tag: 'video[src]', - getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), - }, - ], - toDOM: node => [ - 'video', - { - src: node.attrs.src, - width: '400', - controls: true, - 'data-setup': '{}', - 'data-title': node.attrs.alt, - }, - ], - }; - } - - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.image(state, node); - state.closeBlock(node); +export default class Video extends Playable { + constructor() { + super(); + this.mediaType = 'video'; + this.extraElementAttrs = { width: '400' }; } } diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index c3e2c09f1d5..3856832de90 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,4 +1,5 @@ import flash from '~/flash'; +import $ from 'jquery'; import { sprintf, __ } from '../../locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the @@ -18,9 +19,12 @@ import { sprintf, __ } from '../../locale'; // This is an arbitrary number; Can be iterated upon when suitable. const MAX_CHAR_LIMIT = 5000; -export default function renderMermaid($els) { +function renderMermaids($els) { if (!$els.length) return; + // A diagram may have been truncated in search results which will cause errors, so abort the render. + if (document.querySelector('body').dataset.page === 'search:show') return; + import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { mermaid.initialize({ @@ -92,3 +96,19 @@ export default function renderMermaid($els) { flash(`Can't load mermaid module: ${err}`); }); } + +export default function renderMermaid($els) { + if (!$els.length) return; + + const visibleMermaids = $els.filter(function filter() { + return $(this).closest('details').length === 0; + }); + + renderMermaids(visibleMermaids); + + $els.closest('details').one('toggle', function toggle() { + if (this.open) { + renderMermaids($(this).find('.js-render-mermaid')); + } + }); +} diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index 7cf18d1fd83..2fa3f4fc789 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import '../commons/bootstrap'; // Requires Input behavior @@ -23,10 +23,10 @@ $.fn.requiresInput = function requiresInput() { function requireInput() { // Collect the input values of *all* required fields - const values = _.map($(fieldSelector, $form), field => field.value); + const values = Array.from($(fieldSelector, $form)).map(field => field.value); // Disable the button if any required fields are empty - if (values.length && _.some(values, _.isEmpty)) { + if (values.length && values.some(isEmpty)) { $button.disable(); } else { $button.enable(); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 66cb9fd7672..85636f3e5d2 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -1,6 +1,9 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import Mousetrap from 'mousetrap'; +import Vue from 'vue'; +import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; +import ShortcutsToggle from './shortcuts_toggle.vue'; import axios from '../../lib/utils/axios_utils'; import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; @@ -15,6 +18,15 @@ Mousetrap.stopCallback = (e, element, combo) => { return defaultStopCallback(e, element, combo); }; +function initToggleButton() { + return new Vue({ + el: document.querySelector('.js-toggle-shortcuts'), + render(createElement) { + return createElement(ShortcutsToggle); + }, + }); +} + export default class Shortcuts { constructor() { this.onToggleHelp = this.onToggleHelp.bind(this); @@ -48,6 +60,14 @@ export default class Shortcuts { $(this).remove(); e.preventDefault(); }); + + $('.js-shortcuts-modal-trigger') + .off('click') + .on('click', this.onToggleHelp); + + if (shouldDisableShortcuts()) { + disableShortcuts(); + } } onToggleHelp(e) { @@ -104,7 +124,8 @@ export default class Shortcuts { } return $('.js-more-help-button').remove(); - }); + }) + .then(initToggleButton); } focusFilter(e) { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js index 052e33b4a2b..d5d8edd5ac0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js @@ -1,26 +1,67 @@ import Mousetrap from 'mousetrap'; -import { getLocationHash, visitUrl } from '../../lib/utils/url_utility'; +import { + getLocationHash, + updateHistory, + urlIsDifferent, + urlContainsSha, + getShaFromUrl, +} from '~/lib/utils/url_utility'; +import { updateRefPortionOfTitle } from '~/repository/utils/title'; import Shortcuts from './shortcuts'; const defaults = { skipResetBindings: false, fileBlobPermalinkUrl: null, + fileBlobPermalinkUrlElement: null, }; +function eventHasModifierKeys(event) { + // We ignore alt because I don't think alt clicks normally do anything special? + return event.ctrlKey || event.metaKey || event.shiftKey; +} + export default class ShortcutsBlob extends Shortcuts { constructor(opts) { const options = Object.assign({}, defaults, opts); super(options.skipResetBindings); this.options = options; + this.shortcircuitPermalinkButton(); + Mousetrap.bind('y', this.moveToFilePermalink.bind(this)); } moveToFilePermalink() { - if (this.options.fileBlobPermalinkUrl) { + const permalink = this.options.fileBlobPermalinkUrl; + + if (permalink) { const hash = getLocationHash(); const hashUrlString = hash ? `#${hash}` : ''; - visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`); + + if (urlIsDifferent(permalink)) { + updateHistory({ + url: `${permalink}${hashUrlString}`, + title: document.title, + }); + } + + if (urlContainsSha({ url: permalink })) { + updateRefPortionOfTitle(getShaFromUrl({ url: permalink })); + } + } + } + + shortcircuitPermalinkButton() { + const button = this.options.fileBlobPermalinkUrlElement; + const handleButton = e => { + if (!eventHasModifierKeys(e)) { + e.preventDefault(); + this.moveToFilePermalink(); + } + }; + + if (button) { + button.addEventListener('click', handleButton); } } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js new file mode 100644 index 00000000000..66aa1b752ae --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js @@ -0,0 +1,22 @@ +import Mousetrap from 'mousetrap'; +import 'mousetrap/plugins/pause/mousetrap-pause'; + +const shorcutsDisabledKey = 'shortcutsDisabled'; + +export const shouldDisableShortcuts = () => { + try { + return localStorage.getItem(shorcutsDisabledKey) === 'true'; + } catch (e) { + return false; + } +}; + +export function enableShortcuts() { + localStorage.setItem(shorcutsDisabledKey, false); + Mousetrap.unpause(); +} + +export function disableShortcuts() { + localStorage.setItem(shorcutsDisabledKey, true); + Mousetrap.pause(); +} diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue new file mode 100644 index 00000000000..a53b1b06be9 --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue @@ -0,0 +1,60 @@ +<script> +import { GlToggle, GlSprintf } from '@gitlab/ui'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; + +export default { + components: { + GlSprintf, + GlToggle, + }, + data() { + return { + localStorageUsable: AccessorUtilities.isLocalStorageAccessSafe(), + shortcutsEnabled: !shouldDisableShortcuts(), + }; + }, + methods: { + onChange(value) { + this.shortcutsEnabled = value; + if (value) { + enableShortcuts(); + } else { + disableShortcuts(); + } + }, + }, +}; +</script> + +<template> + <div v-if="localStorageUsable" class="d-inline-flex align-items-center js-toggle-shortcuts"> + <gl-toggle + v-model="shortcutsEnabled" + aria-describedby="shortcutsToggle" + class="prepend-left-10 mb-0" + label-position="right" + @change="onChange" + > + <template #labelOn> + <gl-sprintf + :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Enabled')" + > + <template #screenreaderOnly="{ content }"> + <span class="sr-only">{{ content }}</span> + </template> + </gl-sprintf> + </template> + <template #labelOff> + <gl-sprintf + :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Disabled')" + > + <template #screenreaderOnly="{ content }"> + <span class="sr-only">{{ content }}</span> + </template> + </gl-sprintf> + </template> + </gl-toggle> + <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div> + </div> +</template> diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue new file mode 100644 index 00000000000..2639a099093 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -0,0 +1,51 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; +import BlobContentError from './blob_content_error.vue'; + +export default { + components: { + GlLoadingIcon, + BlobContentError, + }, + props: { + content: { + type: String, + default: '', + required: false, + }, + loading: { + type: Boolean, + default: true, + required: false, + }, + activeViewer: { + type: Object, + required: true, + }, + }, + computed: { + viewer() { + switch (this.activeViewer.type) { + case 'rich': + return RichViewer; + default: + return SimpleViewer; + } + }, + viewerError() { + return this.activeViewer.renderError; + }, + }, +}; +</script> +<template> + <div class="blob-viewer" :data-type="activeViewer.type"> + <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" /> + + <template v-else> + <blob-content-error v-if="viewerError" :viewer-error="viewerError" /> + <component :is="viewer" v-else ref="contentViewer" :content="content" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue new file mode 100644 index 00000000000..0f1af0a962d --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_content_error.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + viewerError: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="file-content code"> + <div class="text-center py-4" v-html="viewerError"></div> + </div> +</template> diff --git a/app/assets/javascripts/blob/components/blob_embeddable.vue b/app/assets/javascripts/blob/components/blob_embeddable.vue new file mode 100644 index 00000000000..26bd0208309 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_embeddable.vue @@ -0,0 +1,41 @@ +<script> +import { GlFormInputGroup, GlButton, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormInputGroup, + GlButton, + GlIcon, + }, + props: { + url: { + type: String, + required: true, + }, + }, + data() { + return { + optionValues: [ + // eslint-disable-next-line no-useless-escape + { name: __('Embed'), value: `<script src='${this.url}.js'><\/script>` }, + { name: __('Share'), value: this.url }, + ], + }; + }, +}; +</script> +<template> + <gl-form-input-group + id="embeddable-text" + :predefined-options="optionValues" + readonly + select-on-click + > + <template #append> + <gl-button new-style data-clipboard-target="#embeddable-text"> + <gl-icon name="copy-to-clipboard" :title="__('Copy')" /> + </gl-button> + </template> + </gl-form-input-group> +</template> diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue new file mode 100644 index 00000000000..b7d9600ec40 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -0,0 +1,82 @@ +<script> +import ViewerSwitcher from './blob_header_viewer_switcher.vue'; +import DefaultActions from './blob_header_default_actions.vue'; +import BlobFilepath from './blob_header_filepath.vue'; +import { SIMPLE_BLOB_VIEWER } from './constants'; + +export default { + components: { + ViewerSwitcher, + DefaultActions, + BlobFilepath, + }, + props: { + blob: { + type: Object, + required: true, + }, + hideDefaultActions: { + type: Boolean, + required: false, + default: false, + }, + hideViewerSwitcher: { + type: Boolean, + required: false, + default: false, + }, + activeViewerType: { + type: String, + required: false, + default: SIMPLE_BLOB_VIEWER, + }, + }, + data() { + return { + viewer: this.hideViewerSwitcher ? null : this.activeViewerType, + }; + }, + computed: { + showViewerSwitcher() { + return !this.hideViewerSwitcher && Boolean(this.blob.simpleViewer && this.blob.richViewer); + }, + showDefaultActions() { + return !this.hideDefaultActions; + }, + }, + watch: { + viewer(newVal, oldVal) { + if (!this.hideViewerSwitcher && newVal !== oldVal) { + this.$emit('viewer-changed', newVal); + } + }, + }, + methods: { + proxyCopyRequest() { + this.$emit('copy'); + }, + }, +}; +</script> +<template> + <div class="js-file-title file-title-flex-parent"> + <blob-filepath :blob="blob"> + <template #filepathPrepend> + <slot name="prepend"></slot> + </template> + </blob-filepath> + + <div class="file-actions d-none d-sm-block"> + <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> + + <slot name="actions"></slot> + + <default-actions + v-if="showDefaultActions" + :raw-path="blob.rawPath" + :active-viewer="viewer" + @copy="proxyCopyRequest" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue new file mode 100644 index 00000000000..6b38b871606 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -0,0 +1,74 @@ +<script> +import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + BTN_COPY_CONTENTS_TITLE, + BTN_DOWNLOAD_TITLE, + BTN_RAW_TITLE, + RICH_BLOB_VIEWER, + SIMPLE_BLOB_VIEWER, +} from './constants'; + +export default { + components: { + GlIcon, + GlButtonGroup, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + rawPath: { + type: String, + required: true, + }, + activeViewer: { + type: String, + default: SIMPLE_BLOB_VIEWER, + required: false, + }, + }, + computed: { + downloadUrl() { + return `${this.rawPath}?inline=false`; + }, + copyDisabled() { + return this.activeViewer === RICH_BLOB_VIEWER; + }, + }, + BTN_COPY_CONTENTS_TITLE, + BTN_DOWNLOAD_TITLE, + BTN_RAW_TITLE, +}; +</script> +<template> + <gl-button-group> + <gl-button + v-gl-tooltip.hover + :aria-label="$options.BTN_COPY_CONTENTS_TITLE" + :title="$options.BTN_COPY_CONTENTS_TITLE" + :disabled="copyDisabled" + data-clipboard-target="#blob-code-content" + > + <gl-icon name="copy-to-clipboard" :size="14" /> + </gl-button> + <gl-button + v-gl-tooltip.hover + :aria-label="$options.BTN_RAW_TITLE" + :title="$options.BTN_RAW_TITLE" + :href="rawPath" + target="_blank" + > + <gl-icon name="doc-code" :size="14" /> + </gl-button> + <gl-button + v-gl-tooltip.hover + :aria-label="$options.BTN_DOWNLOAD_TITLE" + :title="$options.BTN_DOWNLOAD_TITLE" + :href="downloadUrl" + target="_blank" + > + <gl-icon name="download" :size="14" /> + </gl-button> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue new file mode 100644 index 00000000000..6c6a22e2b36 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -0,0 +1,47 @@ +<script> +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; + +export default { + components: { + FileIcon, + ClipboardButton, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + computed: { + blobSize() { + return numberToHumanSize(this.blob.size); + }, + gfmCopyText() { + return `\`${this.blob.path}\``; + }, + }, +}; +</script> +<template> + <div class="file-header-content d-flex align-items-center lh-100"> + <slot name="filepathPrepend"></slot> + + <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" /> + <strong + v-if="blob.name" + class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath" + >{{ blob.name }}</strong + > + + <small class="mr-2">{{ blobSize }}</small> + + <clipboard-button + :text="blob.path" + :gfm="gfmCopyText" + :title="__('Copy file path')" + css-class="btn-clipboard btn-transparent lh-100 position-static" + /> + </div> +</template> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue new file mode 100644 index 00000000000..689fa7638f0 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -0,0 +1,70 @@ +<script> +import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + RICH_BLOB_VIEWER, + RICH_BLOB_VIEWER_TITLE, + SIMPLE_BLOB_VIEWER, + SIMPLE_BLOB_VIEWER_TITLE, +} from './constants'; + +export default { + components: { + GlIcon, + GlButtonGroup, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + default: SIMPLE_BLOB_VIEWER, + required: false, + }, + }, + computed: { + isSimpleViewer() { + return this.value === SIMPLE_BLOB_VIEWER; + }, + isRichViewer() { + return this.value === RICH_BLOB_VIEWER; + }, + }, + methods: { + switchToViewer(viewer) { + if (viewer !== this.value) { + this.$emit('input', viewer); + } + }, + }, + SIMPLE_BLOB_VIEWER, + RICH_BLOB_VIEWER, + SIMPLE_BLOB_VIEWER_TITLE, + RICH_BLOB_VIEWER_TITLE, +}; +</script> +<template> + <gl-button-group class="js-blob-viewer-switcher ml-2"> + <gl-button + v-gl-tooltip.hover + :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" + :title="$options.SIMPLE_BLOB_VIEWER_TITLE" + :selected="isSimpleViewer" + :class="{ active: isSimpleViewer }" + @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" + > + <gl-icon name="code" :size="14" /> + </gl-button> + <gl-button + v-gl-tooltip.hover + :aria-label="$options.RICH_BLOB_VIEWER_TITLE" + :title="$options.RICH_BLOB_VIEWER_TITLE" + :selected="isRichViewer" + :class="{ active: isRichViewer }" + @click="switchToViewer($options.RICH_BLOB_VIEWER)" + > + <gl-icon name="document" :size="14" /> + </gl-button> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js new file mode 100644 index 00000000000..d3fed9e51e9 --- /dev/null +++ b/app/assets/javascripts/blob/components/constants.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents'); +export const BTN_RAW_TITLE = __('Open raw'); +export const BTN_DOWNLOAD_TITLE = __('Download'); + +export const SIMPLE_BLOB_VIEWER = 'simple'; +export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source'); + +export const RICH_BLOB_VIEWER = 'rich'; +export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file'); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 2df7a84ead0..0fb02ca5965 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -117,11 +117,7 @@ export default class FileTemplateMediator { selector.hide(); } }); - - if (this.editor.getValue() !== '') { - this.setTypeSelectorToggleText(item.name); - } - + this.setTypeSelectorToggleText(item.name); this.cacheToggleText(); } diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js index 071022a9a75..35634d63e4a 100644 --- a/app/assets/javascripts/blob/notebook/index.js +++ b/app/assets/javascripts/blob/notebook/index.js @@ -75,10 +75,10 @@ export default () => { class="text-center" v-if="error"> <span v-if="loadError"> - An error occurred whilst loading the file. Please try again later. + An error occurred while loading the file. Please try again later. </span> <span v-else> - An error occurred whilst parsing the file. + An error occurred while parsing the file. </span> </p> </div> diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index 7d5f487c4ba..19778d07983 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import pdfLab from '../../pdf/index.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; export default () => { const el = document.getElementById('js-pdf-viewer'); @@ -8,6 +9,7 @@ export default () => { el, components: { pdfLab, + GlLoadingIcon, }, data() { return { @@ -32,11 +34,7 @@ export default () => { <div class="text-center loading" v-if="loading && !error"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" - aria-label="PDF loading"> - </i> + <gl-loading-icon class="mt-5" size="lg"/> </div> <pdf-lab v-if="!loadError" @@ -47,10 +45,10 @@ export default () => { class="text-center" v-if="error"> <span v-if="loadError"> - An error occurred whilst loading the file. Please try again later. + An error occurred while loading the file. Please try again later. </span> <span v-else> - An error occurred whilst decoding the file. + An error occurred while decoding the file. </span> </p> </div> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index ee889e0f7e0..4a64d9e04f2 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -181,6 +181,8 @@ export default { boardsStore.startMoving(list, issue); + this.$root.$emit('bv::hide::tooltip'); + sortableStart(); }, onAdd: e => { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 7f7510545c6..bdaed17fd09 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -162,6 +162,14 @@ export default { <div class="d-flex board-card-header" dir="auto"> <h4 class="board-card-title append-bottom-0 prepend-top-0"> <icon + v-if="issue.blocked" + v-gl-tooltip + name="issue-block" + :title="__('Blocked issue')" + class="issue-blocked-icon append-right-4" + :aria-label="__('Blocked issue')" + /> + <icon v-if="issue.confidential" v-gl-tooltip name="eye-slash" @@ -233,7 +241,7 @@ export default { :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar" + :img-src="assignee.avatar || assignee.avatar_url" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/issue_count.vue index c50a3c1c0d3..d55f7151d7e 100644 --- a/app/assets/javascripts/boards/components/issue_count.vue +++ b/app/assets/javascripts/boards/components/issue_count.vue @@ -25,7 +25,7 @@ export default { </script> <template> - <div class="issue-count"> + <div class="issue-count text-nowrap"> <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }"> {{ issuesSize }} </span> diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 68ea28e68d9..f77f131c71a 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -26,6 +26,7 @@ export function getBoardSortableDefaultOptions(obj) { scrollSpeed: 20, onStart: sortableStart, onEnd: sortableEnd, + fallbackTolerance: 1, }); Object.keys(obj).forEach(key => { diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 1cee9e5725a..044d96a9aec 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -37,6 +37,7 @@ class ListIssue { this.project_id = obj.project_id; this.timeEstimate = obj.time_estimate; this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; + this.blocked = obj.blocked; if (obj.project) { this.project = new IssueProject(obj.project); diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index b232fea0882..ff50b8ed7d1 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -83,27 +83,7 @@ class List { } save() { - const entity = this.label || this.assignee || this.milestone; - let entityType = ''; - if (this.label) { - entityType = 'label_id'; - } else if (this.assignee) { - entityType = 'assignee_id'; - } else if (IS_EE && this.milestone) { - entityType = 'milestone_id'; - } - - return boardsStore - .createList(entity.id, entityType) - .then(res => res.data) - .then(data => { - this.id = data.id; - this.type = data.list_type; - this.position = data.position; - this.label = data.label; - - return this.getIssues(); - }); + return boardsStore.saveList(this); } destroy() { @@ -181,50 +161,7 @@ class List { } addMultipleIssues(issues, listFrom, newIndex) { - let moveBeforeId = null; - let moveAfterId = null; - - const listHasIssues = issues.every(issue => this.findIssue(issue.id)); - - if (!listHasIssues) { - if (newIndex !== undefined) { - if (this.issues[newIndex - 1]) { - moveBeforeId = this.issues[newIndex - 1].id; - } - - if (this.issues[newIndex]) { - moveAfterId = this.issues[newIndex].id; - } - - this.issues.splice(newIndex, 0, ...issues); - } else { - this.issues.push(...issues); - } - - if (this.label) { - issues.forEach(issue => issue.addLabel(this.label)); - } - - if (this.assignee) { - if (listFrom && listFrom.type === 'assignee') { - issues.forEach(issue => issue.removeAssignee(listFrom.assignee)); - } - issues.forEach(issue => issue.addAssignee(this.assignee)); - } - - if (IS_EE && this.milestone) { - if (listFrom && listFrom.type === 'milestone') { - issues.forEach(issue => issue.removeMilestone(listFrom.milestone)); - } - issues.forEach(issue => issue.addMilestone(this.milestone)); - } - - if (listFrom) { - this.issuesSize += issues.length; - - this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId); - } - } + boardsStore.addMultipleListIssues(this, issues, listFrom, newIndex); } addIssue(issue, listFrom, newIndex) { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 8b737d1dab0..e5ce8b70a4f 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,4 +1,4 @@ -/* eslint-disable no-shadow */ +/* eslint-disable no-shadow, no-param-reassign */ /* global List */ import $ from 'jquery'; @@ -131,6 +131,53 @@ const boardsStore = { listFrom.update(); }, + addMultipleListIssues(list, issues, listFrom, newIndex) { + let moveBeforeId = null; + let moveAfterId = null; + + const listHasIssues = issues.every(issue => list.findIssue(issue.id)); + + if (!listHasIssues) { + if (newIndex !== undefined) { + if (list.issues[newIndex - 1]) { + moveBeforeId = list.issues[newIndex - 1].id; + } + + if (list.issues[newIndex]) { + moveAfterId = list.issues[newIndex].id; + } + + list.issues.splice(newIndex, 0, ...issues); + } else { + list.issues.push(...issues); + } + + if (list.label) { + issues.forEach(issue => issue.addLabel(list.label)); + } + + if (list.assignee) { + if (listFrom && listFrom.type === 'assignee') { + issues.forEach(issue => issue.removeAssignee(listFrom.assignee)); + } + issues.forEach(issue => issue.addAssignee(list.assignee)); + } + + if (IS_EE && list.milestone) { + if (listFrom && listFrom.type === 'milestone') { + issues.forEach(issue => issue.removeMilestone(listFrom.milestone)); + } + issues.forEach(issue => issue.addMilestone(list.milestone)); + } + + if (listFrom) { + list.issuesSize += issues.length; + + list.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId); + } + } + }, + startMoving(list, issue) { Object.assign(this.moving, { list, issue }); }, @@ -408,6 +455,29 @@ const boardsStore = { return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`); }, + saveList(list) { + const entity = list.label || list.assignee || list.milestone; + let entityType = ''; + if (list.label) { + entityType = 'label_id'; + } else if (list.assignee) { + entityType = 'assignee_id'; + } else if (IS_EE && list.milestone) { + entityType = 'milestone_id'; + } + + return this.createList(entity.id, entityType) + .then(res => res.data) + .then(data => { + list.id = data.id; + list.type = data.list_type; + list.position = data.position; + list.label = data.label; + + return list.getIssues(); + }); + }, + getIssuesForList(id, filter = {}) { const data = { id }; Object.keys(filter).forEach(key => { diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js new file mode 100644 index 00000000000..b124502506a --- /dev/null +++ b/app/assets/javascripts/broadcast_notification.js @@ -0,0 +1,21 @@ +import Cookies from 'js-cookie'; + +const handleOnDismiss = ({ currentTarget }) => { + currentTarget.removeEventListener('click', handleOnDismiss); + const { + dataset: { id }, + } = currentTarget; + + Cookies.set(`hide_broadcast_notification_message_${id}`, true); + + const notification = document.querySelector(`.js-broadcast-notification-${id}`); + notification.parentNode.removeChild(notification); +}; + +export default () => { + const dismissButton = document.querySelector('.js-dismiss-current-broadcast-notification'); + + if (dismissButton) { + dismissButton.addEventListener('click', handleOnDismiss); + } +}; diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js index 0bba2a2e160..da33e092086 100644 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import axios from '../lib/utils/axios_utils'; import { s__ } from '../locale'; import Flash from '../flash'; @@ -10,7 +10,7 @@ function generateErrorBoxContent(errors) { const errorList = [].concat(errors).map( errorString => ` <li> - ${_.escape(errorString)} + ${esc(errorString)} </li> `, ); diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 7db9898396b..f8bf778b9e7 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -307,7 +307,7 @@ export default { <a v-if="titleLink" :href="titleLink" - target="blank" + target="_blank" rel="noopener noreferrer" class="js-cluster-application-title" >{{ title }}</a diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 704515cf70c..fe2ad562ad5 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -129,9 +129,6 @@ export default { crossplaneInstalled() { return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; }, - enableClusterApplicationElasticStack() { - return gon.features && gon.features.enableClusterApplicationElasticStack; - }, ingressModSecurityDescription() { const escapedUrl = _.escape(this.ingressModSecurityHelpPath); @@ -655,7 +652,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </div> </application-row> <application-row - v-if="enableClusterApplicationElasticStack" id="elastic_stack" :logo-url="elasticStackLogo" :title="applications.elastic_stack.title" diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 26456fb28db..939c396e1b9 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -257,6 +257,7 @@ export default class ClusterStore { name: environment.name, project: environment.project, environmentPath: environment.environment_path, + logsPath: environment.logs_path, lastDeployment: environment.last_deployment, rolloutStatus: { status: environment.rollout_status ? environment.rollout_status.status : null, diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue new file mode 100644 index 00000000000..0e5f1f0485d --- /dev/null +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -0,0 +1,43 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import Popover from './popover.vue'; + +export default { + components: { + Popover, + }, + computed: { + ...mapState(['currentDefinition', 'currentDefinitionPosition']), + }, + mounted() { + this.blobViewer = document.querySelector('.blob-viewer'); + + this.addGlobalEventListeners(); + this.fetchData(); + }, + beforeDestroy() { + this.removeGlobalEventListeners(); + }, + methods: { + ...mapActions(['fetchData', 'showDefinition']), + addGlobalEventListeners() { + if (this.blobViewer) { + this.blobViewer.addEventListener('click', this.showDefinition); + } + }, + removeGlobalEventListeners() { + if (this.blobViewer) { + this.blobViewer.removeEventListener('click', this.showDefinition); + } + }, + }, +}; +</script> + +<template> + <popover + v-if="currentDefinition" + :position="currentDefinitionPosition" + :data="currentDefinition" + /> +</template> diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue new file mode 100644 index 00000000000..d5bbe430fcd --- /dev/null +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -0,0 +1,76 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, + props: { + position: { + type: Object, + required: true, + }, + data: { + type: Object, + required: true, + }, + }, + data() { + return { + offsetLeft: 0, + }; + }, + computed: { + positionStyles() { + return { + left: `${this.position.x - this.offsetLeft}px`, + top: `${this.position.y + this.position.height}px`, + }; + }, + }, + watch: { + position: { + handler() { + this.$nextTick(() => this.updateOffsetLeft()); + }, + deep: true, + immediate: true, + }, + }, + methods: { + updateOffsetLeft() { + this.offsetLeft = Math.max( + 0, + this.$el.offsetLeft + this.$el.offsetWidth - window.innerWidth + 20, + ); + }, + }, + colorScheme: gon?.user_color_scheme, +}; +</script> + +<template> + <div + :style="positionStyles" + class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show" + > + <div :style="{ left: `${offsetLeft}px` }" class="arrow"></div> + <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom"> + <pre + v-if="hover.language" + ref="code-output" + :class="$options.colorScheme" + class="border-0 bg-transparent m-0 code highlight" + v-html="hover.value" + ></pre> + <p v-else ref="doc-output" class="p-3 m-0"> + {{ hover.value }} + </p> + </div> + <div v-if="data.definition_url" class="popover-body"> + <gl-button :href="data.definition_url" target="_blank" class="w-100" variant="default"> + {{ __('Go to definition') }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js new file mode 100644 index 00000000000..2222c986dfe --- /dev/null +++ b/app/assets/javascripts/code_navigation/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import store from './store'; +import App from './components/app.vue'; + +Vue.use(Vuex); + +export default () => { + const el = document.getElementById('js-code-navigation'); + + store.dispatch('setInitialData', el.dataset); + + return new Vue({ + el, + store, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js new file mode 100644 index 00000000000..2c52074e362 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -0,0 +1,59 @@ +import api from '~/api'; +import * as types from './mutation_types'; +import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils'; + +export default { + setInitialData({ commit }, data) { + commit(types.SET_INITIAL_DATA, data); + }, + requestDataError({ commit }) { + commit(types.REQUEST_DATA_ERROR); + }, + fetchData({ commit, dispatch, state }) { + commit(types.REQUEST_DATA); + + api + .lsifData(state.projectPath, state.commitId, state.blobPath) + .then(({ data }) => { + const normalizedData = data.reduce((acc, d) => { + if (d.hover) { + acc[`${d.start_line}:${d.start_char}`] = d; + addInteractionClass(d); + } + return acc; + }, {}); + + commit(types.REQUEST_DATA_SUCCESS, normalizedData); + }) + .catch(() => dispatch('requestDataError')); + }, + showDefinition({ commit, state }, { target: el }) { + let definition; + let position; + + if (!state.data) return; + + const isCurrentElementPopoverOpen = el.classList.contains('hll'); + + if (getCurrentHoverElement()) { + getCurrentHoverElement().classList.remove('hll'); + } + + if (el.classList.contains('js-code-navigation') && !isCurrentElementPopoverOpen) { + const { lineIndex, charIndex } = el.dataset; + + position = { + x: el.offsetLeft, + y: el.offsetTop, + height: el.offsetHeight, + }; + definition = state.data[`${lineIndex}:${charIndex}`]; + + el.classList.add('hll'); + + setCurrentHoverElement(el); + } + + commit(types.SET_CURRENT_DEFINITION, { definition, position }); + }, +}; diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js new file mode 100644 index 00000000000..fe48f3ac7f5 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/index.js @@ -0,0 +1,10 @@ +import Vuex from 'vuex'; +import createState from './state'; +import actions from './actions'; +import mutations from './mutations'; + +export default new Vuex.Store({ + actions, + mutations, + state: createState(), +}); diff --git a/app/assets/javascripts/code_navigation/store/mutation_types.js b/app/assets/javascripts/code_navigation/store/mutation_types.js new file mode 100644 index 00000000000..29a2897a6fd --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const REQUEST_DATA = 'REQUEST_DATA'; +export const REQUEST_DATA_SUCCESS = 'REQUEST_DATA_SUCCESS'; +export const REQUEST_DATA_ERROR = 'REQUEST_DATA_ERROR'; +export const SET_CURRENT_DEFINITION = 'SET_CURRENT_DEFINITION'; diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js new file mode 100644 index 00000000000..bb833a5adbc --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/mutations.js @@ -0,0 +1,23 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_DATA](state, { projectPath, commitId, blobPath }) { + state.projectPath = projectPath; + state.commitId = commitId; + state.blobPath = blobPath; + }, + [types.REQUEST_DATA](state) { + state.loading = true; + }, + [types.REQUEST_DATA_SUCCESS](state, data) { + state.loading = false; + state.data = data; + }, + [types.REQUEST_DATA_ERROR](state) { + state.loading = false; + }, + [types.SET_CURRENT_DEFINITION](state, { definition, position }) { + state.currentDefinition = definition; + state.currentDefinitionPosition = position; + }, +}; diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js new file mode 100644 index 00000000000..a7b3b289db4 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/state.js @@ -0,0 +1,9 @@ +export default () => ({ + projectPath: null, + commitId: null, + blobPath: null, + loading: false, + data: null, + currentDefinition: null, + currentDefinitionPosition: null, +}); diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js new file mode 100644 index 00000000000..2dee0de6501 --- /dev/null +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -0,0 +1,20 @@ +export const cachedData = new Map(); + +export const getCurrentHoverElement = () => cachedData.get('current'); +export const setCurrentHoverElement = el => cachedData.set('current', el); + +export const addInteractionClass = d => { + let charCount = 0; + const line = document.getElementById(`LC${d.start_line + 1}`); + const el = [...line.childNodes].find(({ textContent }) => { + if (charCount === d.start_char) return true; + charCount += textContent.length; + return false; + }); + + if (el) { + el.setAttribute('data-char-index', d.start_char); + el.setAttribute('data-line-index', d.start_line); + el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); + } +}; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index 2f268419bff..25640f71af2 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -4,6 +4,6 @@ import 'jquery'; import 'jquery-ujs'; import 'vendor/jquery.endless-scroll'; import 'jquery.caret'; // must be imported before at.js -import 'at.js'; +import '@gitlab/at.js'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index dd300b8a307..5e04b0573d2 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,25 +1,3 @@ -// ECMAScript polyfills -import 'core-js/es/array/fill'; -import 'core-js/es/array/find'; -import 'core-js/es/array/find-index'; -import 'core-js/es/array/from'; -import 'core-js/es/array/includes'; -import 'core-js/es/number/is-integer'; -import 'core-js/es/object/assign'; -import 'core-js/es/object/values'; -import 'core-js/es/object/entries'; -import 'core-js/es/promise'; -import 'core-js/es/promise/finally'; -import 'core-js/es/string/code-point-at'; -import 'core-js/es/string/from-code-point'; -import 'core-js/es/string/includes'; -import 'core-js/es/string/starts-with'; -import 'core-js/es/string/ends-with'; -import 'core-js/es/symbol'; -import 'core-js/es/map'; -import 'core-js/es/weak-map'; -import 'core-js/modules/web.url'; - // Browser polyfills import 'formdata-polyfill'; import './polyfills/custom_event'; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index fb7000ee9ed..8dbf0a68c43 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { debounce, uniq } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; @@ -7,11 +7,13 @@ import { __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; export default { components: { GlAreaChart, GlLoadingIcon, + ResizableChartContainer, }, props: { endpoint: { @@ -118,7 +120,7 @@ export default { return this.xAxisRange[this.xAxisRange.length - 1]; }, charts() { - return _.uniq(this.individualCharts); + return uniq(this.individualCharts); }, }, mounted() { @@ -169,7 +171,7 @@ export default { }); }) .catch(() => {}); - this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200)); + this.masterChart.on('datazoom', debounce(this.setIndividualChartsZoom, 200)); }, onIndividualChartCreated(chart) { this.individualCharts.push(chart); @@ -201,25 +203,35 @@ export default { <div v-else-if="showChart" class="contributors-charts"> <h4>{{ __('Commits to') }} {{ branch }}</h4> <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> - <div> + <resizable-chart-container> <gl-area-chart + slot-scope="{ width }" + :width="width" :data="masterChartData" :option="masterChartOptions" :height="masterChartHeight" @created="onMasterChartCreated" /> - </div> + </resizable-chart-container> <div class="row"> - <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6"> + <div + v-for="(contributor, index) in individualChartsData" + :key="index" + class="col-lg-6 col-12" + > <h4>{{ contributor.name }}</h4> <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> - <gl-area-chart - :data="contributor.dates" - :option="individualChartOptions" - :height="individualChartHeight" - @created="onIndividualChartCreated" - /> + <resizable-chart-container> + <gl-area-chart + slot-scope="{ width }" + :width="width" + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </resizable-chart-container> </div> </div> </div> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 3d389cf3db5..59c5586edcd 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -306,9 +306,9 @@ export default { </script> <template> <form name="eks-cluster-configuration-form"> - <h2> + <h4> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} - </h2> + </h4> <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 49a5d4657af..0cfe47dafaf 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -83,7 +83,7 @@ export default { </script> <template> <form name="service-credentials-form"> - <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> + <h4>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h4> <p> {{ s__( diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue index a9d9f0224e3..d6deda25752 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue @@ -16,9 +16,6 @@ export default { ]), ...mapState({ items: 'machineTypes' }), ...mapGetters(['hasZone', 'hasMachineType']), - allDropdownsSelected() { - return this.projectHasBillingEnabled && this.hasZone && this.hasMachineType; - }, isDisabled() { return ( this.isLoading || @@ -65,22 +62,10 @@ export default { .catch(this.fetchFailureHandler); } }, - selectedMachineType() { - this.enableSubmit(); - }, }, methods: { ...mapActions(['fetchMachineTypes']), ...mapActions({ setItem: 'setMachineType' }), - enableSubmit() { - if (this.allDropdownsSelected) { - const submitButtonEl = document.querySelector('.js-gke-cluster-creation-submit'); - - if (submitButtonEl) { - submitButtonEl.removeAttribute('disabled'); - } - } - }, }, }; </script> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue new file mode 100644 index 00000000000..a7e08a5e97f --- /dev/null +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue @@ -0,0 +1,18 @@ +<script> +import { mapGetters } from 'vuex'; + +export default { + computed: { + ...mapGetters(['hasValidData']), + }, +}; +</script> +<template> + <button + type="submit" + :disabled="!hasValidData" + class="js-gke-cluster-creation-submit btn btn-success" + > + {{ s__('ClusterIntegration|Create Kubernetes cluster') }} + </button> +</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js index 729b9404b64..5a64eb09cad 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js @@ -4,6 +4,10 @@ import Flash from '~/flash'; import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; +import GkeSubmitButton from './components/gke_submit_button.vue'; + +import store from './store'; + import * as CONSTANTS from './constants'; const mountComponent = (entryPoint, component, componentName, extraProps = {}) => { @@ -14,6 +18,7 @@ const mountComponent = (entryPoint, component, componentName, extraProps = {}) = return new Vue({ el, + store, components: { [componentName]: component, }, @@ -50,6 +55,10 @@ const mountGkeMachineTypeDropdown = () => { ); }; +const mountGkeSubmitButton = () => { + mountComponent('.js-gke-cluster-creation-submit-container', GkeSubmitButton, 'gke-submit-button'); +}; + const gkeDropdownErrorHandler = () => { Flash(CONSTANTS.GCP_API_ERROR); }; @@ -72,6 +81,7 @@ const initializeGapiClient = () => { mountGkeProjectIdDropdown(); mountGkeZoneDropdown(); mountGkeMachineTypeDropdown(); + mountGkeSubmitButton(); }) .catch(gkeDropdownErrorHandler); }; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js index f9e2e2f74fb..4d4cd223832 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js @@ -1,3 +1,5 @@ export const hasProject = state => Boolean(state.selectedProject.projectId); export const hasZone = state => Boolean(state.selectedZone); export const hasMachineType = state => Boolean(state.selectedMachineType); +export const hasValidData = (state, getters) => + Boolean(state.projectHasBillingEnabled) && getters.hasZone && getters.hasMachineType; diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue index ae8c430dcd6..0db9d2dbcf9 100644 --- a/app/assets/javascripts/cycle_analytics/components/banner.vue +++ b/app/assets/javascripts/cycle_analytics/components/banner.vue @@ -27,7 +27,7 @@ export default { <template> <div class="landing content-block"> <button - :aria-label="__('Dismiss Cycle Analytics introduction box')" + :aria-label="__('Dismiss Value Stream Analytics introduction box')" class="js-ca-dismiss-button dismiss-button" type="button" @click="dismissOverviewDialog" @@ -36,10 +36,10 @@ export default { </button> <div class="svg-container" v-html="iconCycleAnalyticsSplash"></div> <div class="inner-content"> - <h4>{{ __('Introducing Cycle Analytics') }}</h4> + <h4>{{ __('Introducing Value Stream Analytics') }}</h4> <p> {{ - __(`Cycle Analytics gives an overview + __(`Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project.`) }} </p> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 1074ce0e744..6d2b11e39d3 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins'; import Flash from '../flash'; import { __ } from '~/locale'; @@ -28,6 +28,7 @@ export default () => { name: 'CycleAnalytics', components: { GlEmptyState, + GlLoadingIcon, banner, 'stage-issue-component': stageComponent, 'stage-plan-component': stageComponent, @@ -71,7 +72,7 @@ export default () => { }, created() { // Conditional check placed here to prevent this method from being called on the - // new Cycle Analytics page (i.e. the new page will be initialized blank and only + // new Value Stream Analytics page (i.e. the new page will be initialized blank and only // after a group is selected the cycle analyitcs data will be fetched). Once the // old (current) page has been removed this entire created method as well as the // variable itself can be completely removed. @@ -81,7 +82,7 @@ export default () => { methods: { handleError() { this.store.setErrorState(true); - return new Flash(__('There was an error while fetching cycle analytics data.')); + return new Flash(__('There was an error while fetching value stream analytics data.')); }, initDropdown() { const $dropdown = $('.js-ca-dropdown'); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 092c69a01d3..fa5f8ea4005 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -24,9 +24,9 @@ const JumpToDiscussion = Vue.extend({ computed: { buttonText() { if (this.discussionId) { - return __('Jump to next unresolved discussion'); + return __('Jump to next unresolved thread'); } else { - return __('Jump to first unresolved discussion'); + return __('Jump to first unresolved thread'); } }, allResolved() { diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js index daf61e5d467..97296a40d6e 100644 --- a/app/assets/javascripts/diff_notes/models/discussion.js +++ b/app/assets/javascripts/diff_notes/models/discussion.js @@ -1,4 +1,4 @@ -/* eslint-disable camelcase, guard-for-in, no-restricted-syntax */ +/* eslint-disable guard-for-in, no-restricted-syntax */ /* global NoteModel */ import $ from 'jquery'; @@ -40,13 +40,13 @@ class DiscussionModel { return true; } - resolveAllNotes(resolved_by) { + resolveAllNotes(resolvedBy) { for (const noteId in this.notes) { const note = this.notes[noteId]; if (!note.resolved) { note.resolved = true; - note.resolved_by = resolved_by; + note.resolved_by = resolvedBy; } } } diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js index 69a972f644d..9bde18c4edf 100644 --- a/app/assets/javascripts/diff_notes/stores/comments.js +++ b/app/assets/javascripts/diff_notes/stores/comments.js @@ -1,4 +1,4 @@ -/* eslint-disable camelcase, no-restricted-syntax, guard-for-in */ +/* eslint-disable no-restricted-syntax, guard-for-in */ /* global DiscussionModel */ import Vue from 'vue'; @@ -26,11 +26,11 @@ window.CommentsStore = { discussion.createNote(noteObj); }, - update(discussionId, noteId, resolved, resolved_by) { + update(discussionId, noteId, resolved, resolvedBy) { const discussion = this.state[discussionId]; const note = discussion.getNote(noteId); note.resolved = resolved; - note.resolved_by = resolved_by; + note.resolved_by = resolvedBy; }, delete(discussionId, noteId) { const discussion = this.state[discussionId]; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 463d1427805..f9d3d31e152 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -354,7 +354,7 @@ export default { <template> <div v-show="shouldShow"> - <div v-if="isLoading" class="loading"><gl-loading-icon /></div> + <div v-if="isLoading" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :merge-request-diffs="mergeRequestDiffs" @@ -374,7 +374,7 @@ export default { <div :data-can-create-note="getNoteableData.current_user.can_create_note" - class="files d-flex prepend-top-default" + class="files d-flex" > <div v-show="showTreeList" diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 24542126b07..3a2146147cc 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,7 +1,6 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; @@ -16,6 +15,7 @@ export default { Icon, GlLink, GlButton, + GlSprintf, SettingsDropdown, DiffStats, }, @@ -63,9 +63,6 @@ export default { showDropdowns() { return !this.commit && this.mergeRequestDiffs.length; }, - fileTreeIcon() { - return this.showTreeList ? 'collapse-left' : 'expand-left'; - }, toggleFileBrowserTitle() { return this.showTreeList ? __('Hide file browser') : __('Show file browser'); }, @@ -91,7 +88,7 @@ export default { </script> <template> - <div class="mr-version-controls border-top border-bottom"> + <div class="mr-version-controls border-top"> <div class="mr-version-menus-container content-block" :class="{ @@ -108,25 +105,31 @@ export default { :title="toggleFileBrowserTitle" @click="toggleShowTreeList" > - <icon :name="fileTreeIcon" /> + <icon name="file-tree" /> </button> - <div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container"> - Changes between - <compare-versions-dropdown - :other-versions="mergeRequestDiffs" - :merge-request-version="mergeRequestDiff" - :show-commit-count="true" - class="mr-version-dropdown" - /> - and - <compare-versions-dropdown - :other-versions="comparableDiffs" - :base-version-path="baseVersionPath" - :start-version="startVersion" - :target-branch="targetBranch" - class="mr-version-compare-dropdown" - /> - </div> + <gl-sprintf + v-if="showDropdowns" + class="d-flex align-items-center compare-versions-container" + :message="s__('MergeRequest|Compare %{source} and %{target}')" + > + <template #source> + <compare-versions-dropdown + :other-versions="mergeRequestDiffs" + :merge-request-version="mergeRequestDiff" + :show-commit-count="true" + class="mr-version-dropdown" + /> + </template> + <template #target> + <compare-versions-dropdown + :other-versions="comparableDiffs" + :base-version-path="baseVersionPath" + :start-version="startVersion" + :target-branch="targetBranch" + class="mr-version-compare-dropdown" + /> + </template> + </gl-sprintf> <div v-else-if="commit"> {{ __('Viewing commit') }} <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 5d27c6eb865..731c53a7339 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -210,6 +210,9 @@ export default { :text="diffFile.file_path" :gfm="gfmCopyText" css-class="btn-default btn-transparent btn-clipboard" + data-track-event="click_copy_file_button" + data-track-label="diff_copy_file_path_button" + data-track-property="diff_copy_file" /> <small v-if="isModeChanged" ref="fileMode" class="mr-1"> @@ -221,7 +224,7 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-block" + class="file-actions d-none d-sm-flex align-items-center flex-wrap" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <div class="btn-group" role="group"> @@ -233,6 +236,9 @@ export default { :class="{ active: diffHasExpandedDiscussions(diffFile) }" class="js-btn-vue-toggle-comments btn" data-qa-selector="toggle_comments_button" + data-track-event="click_toggle_comments_button" + data-track-label="diff_toggle_comments_button" + data-track-property="diff_toggle_comments" type="button" @click="toggleFileDiscussionWrappers(diffFile)" > @@ -245,6 +251,9 @@ export default { :can-current-user-fork="canCurrentUserFork" :edit-path="diffFile.edit_path" :can-modify-blob="diffFile.can_modify_blob" + data-track-event="click_toggle_edit_button" + data-track-label="diff_toggle_edit_button" + data-track-property="diff_toggle_edit" @showForkMessage="showForkMessage" /> </template> @@ -263,6 +272,9 @@ export default { v-gl-tooltip.hover :title="expandDiffToFullFileTitle" class="expand-file" + data-track-event="click_toggle_view_full_button" + data-track-label="diff_toggle_view_full_button" + data-track-property="diff_toggle_view_full" @click="toggleFullDiff(diffFile.file_path)" > <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> @@ -273,8 +285,11 @@ export default { ref="viewButton" v-gl-tooltip.hover :href="diffFile.view_path" - target="blank" + target="_blank" class="view-file" + data-track-event="click_toggle_view_sha_button" + data-track-label="diff_toggle_view_sha_button" + data-track-property="diff_toggle_view_sha" :title="viewFileButtonText" > <icon name="doc-text" /> @@ -288,6 +303,9 @@ export default { :title="`View on ${diffFile.formatted_external_url}`" target="_blank" rel="noopener noreferrer" + data-track-event="click_toggle_external_button" + data-track-label="diff_toggle_external_button" + data-track-property="diff_toggle_external" class="btn btn-file-option" > <icon name="external-link" /> diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue new file mode 100644 index 00000000000..15e63a1c9ca --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file_row.vue @@ -0,0 +1,40 @@ +<script> +/** + * This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue` + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720 + */ +import FileRow from '~/vue_shared/components/file_row.vue'; +import FileRowStats from './file_row_stats.vue'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; + +export default { + name: 'DiffFileRow', + components: { + FileRow, + FileRowStats, + ChangedFileIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + hideFileStats: { + type: Boolean, + required: true, + }, + }, + computed: { + showFileRowStats() { + return !this.hideFileStats && this.file.type === 'blob'; + }, + }, +}; +</script> + +<template> + <file-row :file="file" v-bind="$attrs" v-on="$listeners"> + <file-row-stats v-if="showFileRowStats" :file="file" class="mr-1" /> + <changed-file-icon :file="file" :size="16" /> + </file-row> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue deleted file mode 100644 index 34aa15856d2..00000000000 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ /dev/null @@ -1,147 +0,0 @@ -<script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; -import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { LINE_POSITION_RIGHT } from '../constants'; - -export default { - components: { - DiffGutterAvatars, - Icon, - }, - props: { - line: { - type: Object, - required: true, - }, - fileHash: { - type: String, - required: true, - }, - contextLinesPath: { - type: String, - required: true, - }, - lineNumber: { - type: Number, - required: false, - default: 0, - }, - linePosition: { - type: String, - required: false, - default: '', - }, - showCommentButton: { - type: Boolean, - required: false, - default: false, - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - isMatchLine: { - type: Boolean, - required: false, - default: false, - }, - isMetaLine: { - type: Boolean, - required: false, - default: false, - }, - isContextLine: { - type: Boolean, - required: false, - default: false, - }, - isHover: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - ...mapState({ - diffViewType: state => state.diffs.diffViewType, - diffFiles: state => state.diffs.diffFiles, - }), - ...mapGetters(['isLoggedIn']), - lineCode() { - return ( - this.line.line_code || - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); - }, - lineHref() { - return `#${this.line.line_code || ''}`; - }, - shouldShowCommentButton() { - return ( - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine && - !this.hasDiscussions - ); - }, - hasDiscussions() { - return this.line.discussions && this.line.discussions.length > 0; - }, - shouldShowAvatarsOnGutter() { - if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) { - return false; - } - return this.showCommentButton && this.hasDiscussions; - }, - shouldRenderCommentButton() { - return this.isLoggedIn && this.showCommentButton; - }, - }, - methods: { - ...mapActions('diffs', [ - 'loadMoreLines', - 'showCommentForm', - 'setHighlightedRow', - 'toggleLineDiscussions', - 'toggleLineDiscussionWrappers', - ]), - handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); - }, - }, -}; -</script> - -<template> - <div> - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - type="button" - class="add-diff-note js-add-diff-note-button qa-diff-comment" - title="Add a comment to this line" - @click="handleCommentButton" - > - <icon :size="12" name="comment" /> - </button> - <a - v-if="lineNumber" - :data-linenumber="lineNumber" - :href="lineHref" - @click="setHighlightedRow(lineCode)" - > - </a> - <diff-gutter-avatars - v-if="shouldShowAvatarsOnGutter" - :discussions="line.discussions" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) - " - /> - </div> -</template> diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 2e5855380af..9d362ceb429 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -1,6 +1,7 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import { n__ } from '~/locale'; +import { isNumber } from 'underscore'; export default { components: { Icon }, @@ -21,11 +22,14 @@ export default { }, computed: { filesText() { - return n__('File', 'Files', this.diffFilesLength); + return n__('file', 'files', this.diffFilesLength); }, isCompareVersionsHeader() { return Boolean(this.diffFilesLength); }, + hasDiffFiles() { + return isNumber(this.diffFilesLength) && this.diffFilesLength >= 0; + }, }, }; </script> @@ -38,15 +42,23 @@ export default { 'd-inline-flex': !isCompareVersionsHeader, }" > - <div v-if="diffFilesLength !== null" class="diff-stats-group"> + <div v-if="hasDiffFiles" class="diff-stats-group"> <icon name="doc-code" class="diff-stats-icon text-secondary" /> - <strong>{{ diffFilesLength }} {{ filesText }}</strong> + <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span> </div> - <div class="diff-stats-group cgreen"> - <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + <div + class="diff-stats-group cgreen d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>+</span> + <span class="js-file-addition-line">{{ addedLines }}</span> </div> - <div class="diff-stats-group cred"> - <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + <div + class="diff-stats-group cred d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>-</span> + <span class="js-file-deletion-line">{{ removedLines }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 0f3e9208d21..9544fbe9fc5 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -1,21 +1,24 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import DiffLineGutterContent from './diff_line_gutter_content.vue'; +import { GlIcon } from '@gitlab/ui'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import DiffGutterAvatars from './diff_gutter_avatars.vue'; import { MATCH_LINE_TYPE, CONTEXT_LINE_TYPE, + LINE_POSITION_RIGHT, EMPTY_CELL_TYPE, OLD_LINE_TYPE, OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME, - INLINE_DIFF_VIEW_TYPE, } from '../constants'; export default { components: { - DiffLineGutterContent, + DiffGutterAvatars, + GlIcon, }, props: { line: { @@ -33,12 +36,6 @@ export default { isHighlighted: { type: Boolean, required: true, - default: false, - }, - diffViewType: { - type: String, - required: false, - default: INLINE_DIFF_VIEW_TYPE, }, showCommentButton: { type: Boolean, @@ -73,6 +70,38 @@ export default { }, computed: { ...mapGetters(['isLoggedIn']), + lineCode() { + return ( + this.line.line_code || + (this.line.left && this.line.left.line_code) || + (this.line.right && this.line.right.line_code) + ); + }, + lineHref() { + return `#${this.line.line_code || ''}`; + }, + shouldShowCommentButton() { + return ( + this.isHover && + !this.isMatchLine && + !this.isContextLine && + !this.isMetaLine && + !this.hasDiscussions + ); + }, + hasDiscussions() { + return this.line.discussions && this.line.discussions.length > 0; + }, + shouldShowAvatarsOnGutter() { + if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) { + return false; + } + return this.showCommentButton && this.hasDiscussions; + }, + shouldRenderCommentButton() { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead && this.isLoggedIn && this.showCommentButton; + }, isMatchLine() { return this.line.type === MATCH_LINE_TYPE; }, @@ -107,24 +136,45 @@ export default { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, }, - methods: mapActions('diffs', ['setHighlightedRow']), + methods: { + ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), + handleCommentButton() { + this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); + }, + }, }; </script> <template> - <td :class="classNameMap"> - <diff-line-gutter-content - :line="line" - :file-hash="fileHash" - :context-lines-path="contextLinesPath" - :line-position="linePosition" - :line-number="lineNumber" - :show-comment-button="showCommentButton" - :is-hover="isHover" - :is-bottom="isBottom" - :is-match-line="isMatchLine" - :is-context-line="isContentLine" - :is-meta-line="isMetaLine" - /> + <td ref="td" :class="classNameMap"> + <div> + <button + v-if="shouldRenderCommentButton" + v-show="shouldShowCommentButton" + ref="addDiffNoteButton" + type="button" + class="add-diff-note js-add-diff-note-button qa-diff-comment" + title="Add a comment to this line" + @click="handleCommentButton" + > + <gl-icon :size="12" name="comment" /> + </button> + <a + v-if="lineNumber" + ref="lineNumberRef" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode)" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="line.discussions" + :discussions-expanded="line.discussionsExpanded" + @toggleLineDiscussions=" + toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + " + /> + </div> </td> </template> diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 0129763161a..08e991c4791 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -31,7 +31,7 @@ export default { data-toggle="dropdown" data-display="static" > - <icon name="settings" /> <icon name="arrow-down" /> + <icon name="settings" /> <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3"> <div> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 30be2e68e76..eca9091f92f 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -3,8 +3,8 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import FileRow from '~/vue_shared/components/file_row.vue'; -import FileRowStats from './file_row_stats.vue'; +import FileTree from '~/vue_shared/components/file_tree.vue'; +import DiffFileRow from './diff_file_row.vue'; export default { directives: { @@ -12,7 +12,7 @@ export default { }, components: { Icon, - FileRow, + FileTree, }, props: { hideFileStats: { @@ -48,9 +48,6 @@ export default { return acc; }, []); }, - fileRowExtraComponent() { - return this.hideFileStats ? null : FileRowStats; - }, }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), @@ -58,9 +55,10 @@ export default { this.search = ''; }, }, - searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), { - modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl', + searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), { + modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+', }), + DiffFileRow, }; </script> @@ -91,14 +89,13 @@ export default { </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> <template v-if="filteredTreeList.length"> - <file-row + <file-tree v-for="file in filteredTreeList" :key="file.key" :file="file" :level="0" - :hide-extra-on-tree="true" - :extra-component="fileRowExtraComponent" - :show-changed-icon="true" + :hide-file-stats="hideFileStats" + :file-row-component="$options.DiffFileRow" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" /> diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index b920e041135..bd85105ccb4 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -111,15 +111,22 @@ export const fetchDiffFilesBatch = ({ commit, state }) => { commit(types.SET_BATCH_LOADING, true); commit(types.SET_RETRIEVING_BATCHES, true); - const getBatch = page => + const getBatch = (page = 1) => axios .get(state.endpointBatch, { - params: { ...urlParams, page }, + params: { + ...urlParams, + page, + }, }) .then(({ data: { pagination, diff_files } }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); - if (!pagination.next_page) commit(types.SET_RETRIEVING_BATCHES, false); + + if (!pagination.next_page) { + commit(types.SET_RETRIEVING_BATCHES, false); + } + return pagination.next_page; }) .then(nextPage => nextPage && getBatch(nextPage)) @@ -132,6 +139,11 @@ export const fetchDiffFilesBatch = ({ commit, state }) => { export const fetchDiffFilesMeta = ({ commit, state }) => { const worker = new TreeWorker(); + const urlParams = {}; + + if (state.useSingleDiffStyle) { + urlParams.view = state.diffViewType; + } commit(types.SET_LOADING, true); @@ -142,16 +154,17 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { }); return axios - .get(state.endpointMetadata) + .get(mergeUrlParams(urlParams, state.endpointMetadata)) .then(({ data }) => { const strippedData = { ...data }; + delete strippedData.diff_files; commit(types.SET_LOADING, false); commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []); commit(types.SET_DIFF_DATA, strippedData); - prepareDiffData(data); - worker.postMessage(data.diff_files); + worker.postMessage(prepareDiffData(data, state.diffFiles)); + return data; }) .catch(() => worker.terminate()); @@ -226,7 +239,7 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const nextFile = state.diffFiles.find( file => !file.renderIt && - (file.viewer && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text)), + (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)), ); if (nextFile) { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 1505be1a0b2..c26411af5d7 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -148,8 +148,8 @@ export default { }, [types.ADD_COLLAPSED_DIFFS](state, { file, data }) { - prepareDiffData(data); - const [newFileData] = data.diff_files.filter(f => f.file_hash === file.file_hash); + const files = prepareDiffData(data); + const [newFileData] = files.filter(f => f.file_hash === file.file_hash); const selectedFile = state.diffFiles.find(f => f.file_hash === file.file_hash); Object.assign(selectedFile, { ...newFileData }); }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index b379f1fabef..80972d2aeb8 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -217,30 +217,19 @@ function diffFileUniqueId(file) { return `${file.content_sha}-${file.file_hash}`; } -function combineDiffFilesWithPriorFiles(files, prior = []) { - files.forEach(file => { - const id = diffFileUniqueId(file); - const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id); - - if (oldMatch) { - const missingInline = !file.highlighted_diff_lines; - const missingParallel = !file.parallel_diff_lines; - - if (missingInline) { - Object.assign(file, { - highlighted_diff_lines: oldMatch.highlighted_diff_lines, - }); - } +function mergeTwoFiles(target, source) { + const originalInline = target.highlighted_diff_lines; + const originalParallel = target.parallel_diff_lines; + const missingInline = !originalInline.length; + const missingParallel = !originalParallel.length; - if (missingParallel) { - Object.assign(file, { - parallel_diff_lines: oldMatch.parallel_diff_lines, - }); - } - } - }); - - return files; + return { + ...target, + highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline, + parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel, + renderIt: source.renderIt, + collapsed: source.collapsed, + }; } function ensureBasicDiffFileLines(file) { @@ -260,13 +249,16 @@ function cleanRichText(text) { } function prepareLine(line) { - return Object.assign(line, { - rich_text: cleanRichText(line.rich_text), - discussionsExpanded: true, - discussions: [], - hasForm: false, - text: undefined, - }); + if (!line.alreadyPrepared) { + Object.assign(line, { + rich_text: cleanRichText(line.rich_text), + discussionsExpanded: true, + discussions: [], + hasForm: false, + text: undefined, + alreadyPrepared: true, + }); + } } function prepareDiffFileLines(file) { @@ -288,11 +280,11 @@ function prepareDiffFileLines(file) { parallelLinesCount += 1; prepareLine(line.right); } + }); - Object.assign(file, { - inlineLinesCount: inlineLines.length, - parallelLinesCount, - }); + Object.assign(file, { + inlineLinesCount: inlineLines.length, + parallelLinesCount, }); return file; @@ -318,11 +310,26 @@ function finalizeDiffFile(file) { return file; } -export function prepareDiffData(diffData, priorFiles) { - return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles) +function deduplicateFilesList(files) { + const dedupedFiles = files.reduce((newList, file) => { + const id = diffFileUniqueId(file); + + return { + ...newList, + [id]: newList[id] ? mergeTwoFiles(newList[id], file) : file, + }; + }, {}); + + return Object.values(dedupedFiles); +} + +export function prepareDiffData(diff, priorFiles = []) { + const cleanedFiles = (diff.diff_files || []) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) .map(finalizeDiffFile); + + return deduplicateFilesList([...priorFiles, ...cleanedFiles]); } export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index b973316b3b9..218bf41cd58 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import $ from 'jquery'; import Pikaday from 'pikaday'; import dateFormat from 'dateformat'; diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js new file mode 100644 index 00000000000..8711f6e65af --- /dev/null +++ b/app/assets/javascripts/editor/editor_lite.js @@ -0,0 +1,68 @@ +import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import whiteTheme from '~/ide/lib/themes/white'; +import { defaultEditorOptions } from '~/ide/lib/editor_options'; +import { clearDomElement } from './utils'; + +export default class Editor { + constructor(options = {}) { + this.editorEl = null; + this.blobContent = ''; + this.blobPath = ''; + this.instance = null; + this.model = null; + this.options = { + ...defaultEditorOptions, + ...options, + }; + + Editor.setupMonacoTheme(); + } + + static setupMonacoTheme() { + monacoEditor.defineTheme('white', whiteTheme); + monacoEditor.setTheme('white'); + } + + createInstance({ el = undefined, blobPath = '', blobContent = '' } = {}) { + if (!el) return; + this.editorEl = el; + this.blobContent = blobContent; + this.blobPath = blobPath; + + clearDomElement(this.editorEl); + + this.model = monacoEditor.createModel( + this.blobContent, + undefined, + new Uri('gitlab', false, this.blobPath), + ); + + monacoEditor.onDidCreateEditor(this.renderEditor.bind(this)); + + this.instance = monacoEditor.create(this.editorEl, this.options); + this.instance.setModel(this.model); + } + + dispose() { + return this.instance && this.instance.dispose(); + } + + renderEditor() { + delete this.editorEl.dataset.editorLoading; + } + + updateModelLanguage(path) { + if (path === this.blobPath) return; + this.blobPath = path; + const ext = `.${path.split('.').pop()}`; + const language = monacoLanguages + .getLanguages() + .find(lang => lang.extensions.indexOf(ext) !== -1); + const id = language ? language.id : 'plaintext'; + monacoEditor.setModelLanguage(this.model, id); + } + + getValue() { + return this.model.getValue(); + } +} diff --git a/app/assets/javascripts/editor/utils.js b/app/assets/javascripts/editor/utils.js new file mode 100644 index 00000000000..d8b6396b671 --- /dev/null +++ b/app/assets/javascripts/editor/utils.js @@ -0,0 +1,11 @@ +export const clearDomElement = el => { + if (!el || !el.firstChild) return; + + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +export default () => ({ + clearDomElement, +}); diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index cdf62259479..0a978ab5869 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -41,7 +41,7 @@ export default { <div class="environments-container"> <gl-loading-icon v-if="isLoading" - :size="3" + size="md" class="prepend-top-default" label="Loading environments" /> diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue new file mode 100644 index 00000000000..2f9e9cb628f --- /dev/null +++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue @@ -0,0 +1,107 @@ +<script> +import { GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlLink, + GlModal, + GlSprintf, + ModalCopyButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + instructionText: { + step1: s__( + 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.', + ), + step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'), + step3: s__( + `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`, + ), + }, + modalInfo: { + closeText: s__('EnableReviewApp|Close'), + copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), + copyString: `deploy_review + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_ENVIRONMENT_SLUG.example.com + only: branches + except: master`, + id: 'enable-review-app-info', + title: s__('ReviewApp|Enable Review App'), + }, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal="$options.modalInfo.id" + variant="info" + category="secondary" + type="button" + class="js-enable-review-app-button" + > + {{ s__('Environments|Enable review app') }} + </gl-button> + <gl-modal + :modal-id="$options.modalInfo.id" + :title="$options.modalInfo.title" + size="lg" + class="text-2 ws-normal" + ok-only + ok-variant="light" + :ok-title="$options.modalInfo.closeText" + > + <p> + <gl-sprintf :message="$options.instructionText.step1"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link + href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + <div> + <p> + <gl-sprintf :message="$options.instructionText.step2"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <div class="flex align-items-start"> + <pre class="w-100"> {{ $options.modalInfo.copyString }} </pre> + <modal-copy-button + :title="$options.modalInfo.copyToClipboardText" + :text="$options.modalInfo.copyString" + :modal-id="$options.modalInfo.id" + css-classes="border-0" + /> + </div> + </div> + <p> + <gl-sprintf :message="$options.instructionText.step3"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 50c667e6966..07b8d20fde0 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,19 +1,23 @@ <script> +import { GlButton } from '@gitlab/ui'; import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin'; -import Flash from '../../flash'; -import { s__ } from '../../locale'; +import Flash from '~/flash'; +import { s__ } from '~/locale'; import emptyState from './empty_state.vue'; import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; -import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; +import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; +import EnableReviewAppButton from './enable_review_app_button.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; export default { components: { + ConfirmRollbackModal, emptyState, + EnableReviewAppButton, + GlButton, StopEnvironmentModal, - ConfirmRollbackModal, }, mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin], @@ -96,10 +100,16 @@ export default { <div class="top-area"> <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> - <div v-if="canCreateEnvironment && !isLoading" class="nav-controls"> - <a :href="newEnvironmentPath" class="btn btn-success"> + <div class="nav-controls"> + <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" /> + <gl-button + v-if="canCreateEnvironment && !isLoading" + :href="newEnvironmentPath" + category="primary" + variant="success" + > {{ s__('Environments|New environment') }} - </a> + </gl-button> </div> </div> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 30299ccc7bc..3f316643784 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -162,15 +162,14 @@ export default { :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" :has-legacy-app-label="model.hasLegacyAppLabel" - :project-path="model.project_path" - :environment-name="model.name" + :logs-path="model.logs_path" /> </div> </div> <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon :size="2" class="prepend-top-16" /> + <gl-loading-icon size="md" class="prepend-top-16" /> </div> <template v-else> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 34374e306a4..1c5884b541c 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -52,6 +52,7 @@ export default { this.store.storeAvailableCount(resp.data.available_count); this.store.storeStoppedCount(resp.data.stopped_count); this.store.storeEnvironments(resp.data.environments); + this.store.setReviewAppDetails(resp.data.review_app); this.store.setPagination(resp.headers); } }, diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 81c257acd53..6b7c1ff627d 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -14,6 +14,7 @@ export default class EnvironmentsStore { this.state.stoppedCounter = 0; this.state.availableCounter = 0; this.state.paginationInformation = {}; + this.state.reviewAppDetails = {}; return this; } @@ -104,6 +105,11 @@ export default class EnvironmentsStore { return paginationInformation; } + setReviewAppDetails(details = {}) { + this.state.reviewAppDetails = details; + return details; + } + /** * Stores the number of available environments. * diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js new file mode 100644 index 00000000000..60b217443de --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/constants.js @@ -0,0 +1,21 @@ +export const severityLevel = { + FATAL: 'fatal', + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + DEBUG: 'debug', +}; + +export const severityLevelVariant = { + [severityLevel.FATAL]: 'danger', + [severityLevel.ERROR]: 'dark', + [severityLevel.WARNING]: 'warning', + [severityLevel.INFO]: 'info', + [severityLevel.DEBUG]: 'light', +}; + +export const errorStatus = { + IGNORED: 'ignored', + RESOLVED: 'resolved', + UNRESOLVED: 'unresolved', +}; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 819d501cba6..7abe3be3e99 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -2,7 +2,15 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import dateFormat from 'dateformat'; import createFlash from '~/flash'; -import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { + GlButton, + GlFormInput, + GlLink, + GlLoadingIcon, + GlBadge, + GlAlert, + GlSprintf, +} from '@gitlab/ui'; import { __, sprintf, n__ } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -11,6 +19,7 @@ import Stacktrace from './stacktrace.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { trackClickErrorLinkToSentryOptions } from '../utils'; +import { severityLevel, severityLevelVariant, errorStatus } from './constants'; import query from '../queries/details.query.graphql'; @@ -25,16 +34,14 @@ export default { Icon, Stacktrace, GlBadge, + GlAlert, + GlSprintf, }, directives: { TrackEvent: TrackEventDirective, }, mixins: [timeagoMixin], props: { - listPath: { - type: String, - required: true, - }, issueUpdatePath: { type: String, required: true, @@ -47,10 +54,6 @@ export default { type: String, required: true, }, - issueDetailsPath: { - type: String, - required: true, - }, issueStackTracePath: { type: String, required: true, @@ -65,7 +68,7 @@ export default { }, }, apollo: { - GQLerror: { + error: { query, variables() { return { @@ -74,57 +77,54 @@ export default { }; }, pollInterval: 2000, - update: data => data.project.sentryDetailedError, + update: data => data.project.sentryErrors.detailedError, error: () => createFlash(__('Failed to load error details from Sentry.')), result(res) { - if (res.data.project?.sentryDetailedError) { - this.$apollo.queries.GQLerror.stopPolling(); + if (res.data.project?.sentryErrors?.detailedError) { + this.$apollo.queries.error.stopPolling(); + this.setStatus(this.error.status); } }, }, }, data() { return { - GQLerror: null, + error: null, issueCreationInProgress: false, + isAlertVisible: false, + closedIssueId: null, }; }, computed: { ...mapState('details', [ - 'error', - 'loading', 'loadingStacktrace', 'stacktraceData', 'updatingResolveStatus', 'updatingIgnoreStatus', + 'errorStatus', ]), ...mapGetters('details', ['stacktrace']), reported() { return sprintf( __('Reported %{timeAgo} by %{reportedBy}'), { - reportedBy: `<strong>${this.GQLerror.culprit}</strong>`, + reportedBy: `<strong>${this.error.culprit}</strong>`, timeAgo: this.timeFormatted(this.stacktraceData.date_received), }, false, ); }, firstReleaseLink() { - return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`; + return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseShortVersion}`; }, lastReleaseLink() { - return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`; - }, - showDetails() { - return Boolean( - !this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror, - ); + return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseShortVersion}`; }, showStacktrace() { - return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); + return Boolean(this.stacktrace?.length); }, issueTitle() { - return this.GQLerror.title; + return this.error.title; }, issueDescription() { return sprintf( @@ -133,13 +133,13 @@ export default { ), { description: '# Error Details:\n', - errorUrl: `${this.GQLerror.externalUrl}\n`, - firstSeen: `\n${this.GQLerror.firstSeen}\n`, - lastSeen: `${this.GQLerror.lastSeen}\n`, - countLabel: n__('- Event', '- Events', this.GQLerror.count), - count: `${this.GQLerror.count}\n`, - userCountLabel: n__('- User', '- Users', this.GQLerror.userCount), - userCount: `${this.GQLerror.userCount}\n`, + errorUrl: `${this.error.externalUrl}\n`, + firstSeen: `\n${this.error.firstSeen}\n`, + lastSeen: `${this.error.lastSeen}\n`, + countLabel: n__('- Event', '- Events', this.error.count), + count: `${this.error.count}\n`, + userCountLabel: n__('- User', '- Users', this.error.userCount), + userCount: `${this.error.userCount}\n`, }, false, ); @@ -147,20 +147,50 @@ export default { errorLevel() { return sprintf(__('level: %{level}'), { level: this.error.tags.level }); }, + errorSeverityVariant() { + return ( + severityLevelVariant[this.error.tags.level] || severityLevelVariant[severityLevel.ERROR] + ); + }, + ignoreBtnLabel() { + return this.errorStatus !== errorStatus.IGNORED ? __('Ignore') : __('Undo ignore'); + }, + resolveBtnLabel() { + return this.errorStatus !== errorStatus.RESOLVED ? __('Resolve') : __('Unresolve'); + }, }, mounted() { - this.startPollingDetails(this.issueDetailsPath); this.startPollingStacktrace(this.issueStackTracePath); }, methods: { - ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']), + ...mapActions('details', [ + 'startPollingStacktrace', + 'updateStatus', + 'setStatus', + 'updateResolveStatus', + 'updateIgnoreStatus', + ]), trackClickErrorLinkToSentryOptions, createIssue() { this.issueCreationInProgress = true; this.$refs.sentryIssueForm.submit(); }, - updateIssueStatus(status) { - this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status }); + onIgnoreStatusUpdate() { + const status = + this.errorStatus === errorStatus.IGNORED ? errorStatus.UNRESOLVED : errorStatus.IGNORED; + this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }); + }, + onResolveStatusUpdate() { + const status = + this.errorStatus === errorStatus.RESOLVED ? errorStatus.UNRESOLVED : errorStatus.RESOLVED; + + // eslint-disable-next-line promise/catch-or-return + this.updateResolveStatus({ endpoint: this.issueUpdatePath, status }).then(res => { + this.closedIssueId = res.closed_issue_iid; + if (this.closedIssueId) { + this.isAlertVisible = true; + } + }); }, formatDate(date) { return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; @@ -171,29 +201,43 @@ export default { <template> <div> - <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3"> + <div v-if="$apollo.queries.error.loading" class="py-3"> <gl-loading-icon :size="3" /> </div> - <div v-else-if="showDetails" class="error-details"> + <div v-else-if="error" class="error-details"> + <gl-alert v-if="isAlertVisible" @dismiss="isAlertVisible = false"> + <gl-sprintf + :message=" + __('The associated issue #%{issueId} has been closed as the error is now resolved.') + " + > + <template #issueId> + <span>{{ closedIssueId }}</span> + </template> + </gl-sprintf> + </gl-alert> + <div class="top-area align-items-center justify-content-between py-3"> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> - <div class="d-inline-flex"> + <div class="d-inline-flex ml-lg-auto"> <loading-button - :label="__('Ignore')" + :label="ignoreBtnLabel" :loading="updatingIgnoreStatus" - @click="updateIssueStatus('ignored')" + data-qa-selector="update_ignore_status_button" + @click="onIgnoreStatusUpdate" /> <loading-button class="btn-outline-info ml-2" - :label="__('Resolve')" + :label="resolveBtnLabel" :loading="updatingResolveStatus" - @click="updateIssueStatus('resolved')" + data-qa-selector="update_resolve_status_button" + @click="onResolveStatusUpdate" /> <gl-button - v-if="error.gitlab_issue" + v-if="error.gitlabIssuePath" class="ml-2" data-qa-selector="view_issue_button" - :href="error.gitlab_issue" + :href="error.gitlabIssuePath" variant="success" > {{ __('View issue') }} @@ -207,13 +251,13 @@ export default { <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> <input name="issue[description]" :value="issueDescription" type="hidden" /> <gl-form-input - :value="GQLerror.sentryId" + :value="error.sentryId" class="hidden" name="issue[sentry_issue_attributes][sentry_issue_identifier]" /> <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> <loading-button - v-if="!error.gitlab_issue" + v-if="!error.gitlabIssuePath" class="btn-success" :label="__('Create issue')" :loading="issueCreationInProgress" @@ -224,65 +268,67 @@ export default { </div> </div> <div> - <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top"> - <h2 class="text-truncate">{{ GQLerror.title }}</h2> + <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ error.title }}</h2> </tooltip-on-truncate> <template v-if="error.tags"> - <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2" - >{{ errorLevel }} + <gl-badge + v-if="error.tags.level" + :variant="errorSeverityVariant" + class="rounded-pill mr-2" + > + {{ errorLevel }} </gl-badge> <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill" >{{ error.tags.logger }} </gl-badge> </template> <ul> - <li v-if="GQLerror.gitlabCommit"> + <li v-if="error.gitlabCommit"> <strong class="bold">{{ __('GitLab commit') }}:</strong> - <gl-link :href="GQLerror.gitlabCommitPath"> - <span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span> + <gl-link :href="error.gitlabCommitPath"> + <span>{{ error.gitlabCommit.substr(0, 10) }}</span> </gl-link> </li> - <li v-if="error.gitlab_issue"> + <li v-if="error.gitlabIssuePath"> <strong class="bold">{{ __('GitLab Issue') }}:</strong> - <gl-link :href="error.gitlab_issue"> - <span>{{ error.gitlab_issue }}</span> + <gl-link :href="error.gitlabIssuePath"> + <span>{{ error.gitlabIssuePath }}</span> </gl-link> </li> <li> <strong class="bold">{{ __('Sentry event') }}:</strong> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)" + v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)" class="d-inline-flex align-items-center" - :href="GQLerror.externalUrl" + :href="error.externalUrl" target="_blank" > - <span class="text-truncate">{{ GQLerror.externalUrl }}</span> + <span class="text-truncate">{{ error.externalUrl }}</span> <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> </li> - <li v-if="GQLerror.firstReleaseShortVersion"> + <li v-if="error.firstReleaseShortVersion"> <strong class="bold">{{ __('First seen') }}:</strong> - {{ formatDate(GQLerror.firstSeen) }} + {{ formatDate(error.firstSeen) }} <gl-link :href="firstReleaseLink" target="_blank"> - <span> - {{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }} - </span> + <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span> </gl-link> </li> - <li v-if="GQLerror.lastReleaseShortVersion"> + <li v-if="error.lastReleaseShortVersion"> <strong class="bold">{{ __('Last seen') }}:</strong> - {{ formatDate(GQLerror.lastSeen) }} + {{ formatDate(error.lastSeen) }} <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span> + <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span> </gl-link> </li> <li> <strong class="bold">{{ __('Events') }}:</strong> - <span>{{ GQLerror.count }}</span> + <span>{{ error.count }}</span> </li> <li> <strong class="bold">{{ __('Users') }}:</strong> - <span>{{ GQLerror.userCount }}</span> + <span>{{ error.userCount }}</span> </li> </ul> @@ -290,7 +336,7 @@ export default { <gl-loading-icon :size="3" /> </div> - <template v-if="showStacktrace"> + <template v-else-if="showStacktrace"> <h3 class="my-4">{{ __('Stack trace') }}</h3> <stacktrace :entries="stacktrace" /> </template> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 3280ff48129..70f257180c6 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -13,53 +13,53 @@ import { GlDropdownDivider, GlTooltipDirective, GlPagination, + GlButtonGroup, } from '@gitlab/ui'; import AccessorUtils from '~/lib/utils/accessor'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; -import _ from 'underscore'; +import { isEmpty } from 'lodash'; + +export const tableDataClass = 'table-col d-flex d-sm-table-cell align-items-center'; export default { FIRST_PAGE: 1, PREV_PAGE: 1, NEXT_PAGE: 2, + statusButtons: [ + { status: 'ignored', icon: 'eye-slash', title: __('Ignore') }, + { status: 'resolved', icon: 'check-circle', title: __('Resolve') }, + ], fields: [ { key: 'error', label: __('Error'), thClass: 'w-60p', - tdClass: 'table-col d-flex d-sm-table-cell px-3', + tdClass: `${tableDataClass} px-3`, }, { key: 'events', label: __('Events'), thClass: 'text-right', - tdClass: 'table-col d-flex d-sm-table-cell', + tdClass: `${tableDataClass}`, }, { key: 'users', label: __('Users'), thClass: 'text-right', - tdClass: 'table-col d-flex d-sm-table-cell', + tdClass: `${tableDataClass}`, }, { key: 'lastSeen', label: __('Last seen'), - thClass: '', - tdClass: 'table-col d-flex d-sm-table-cell', - }, - { - key: 'ignore', - label: '', - thClass: 'w-3rem', - tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + thClass: 'w-15p', + tdClass: `${tableDataClass}`, }, { - key: 'resolved', + key: 'status', label: '', - thClass: 'w-3rem', - tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + tdClass: `${tableDataClass} text-right`, }, { key: 'details', @@ -86,6 +86,7 @@ export default { Icon, GlPagination, TimeAgo, + GlButtonGroup, }, directives: { GlTooltip: GlTooltipDirective, @@ -138,7 +139,7 @@ export default { 'cursor', ]), paginationRequired() { - return !_.isEmpty(this.pagination); + return !isEmpty(this.pagination); }, }, watch: { @@ -167,6 +168,7 @@ export default { 'setIndexPath', 'fetchPaginatedResults', 'updateStatus', + 'removeIgnoredResolvedErrors', ]), setSearchText(text) { this.errorSearchQuery = text; @@ -195,9 +197,9 @@ export default { updateIssueStatus(errorId, status) { this.updateStatus({ endpoint: this.getIssueUpdatePath(errorId), - redirectUrl: this.listPath, status, }); + this.removeIgnoredResolvedErrors(errorId); }, }, }; @@ -234,7 +236,6 @@ export default { </gl-dropdown> <div class="filtered-search-input-container flex-fill"> <gl-form-input - v-model="errorSearchQuery" class="pl-2 filtered-search" :disabled="loading" :placeholder="__('Search or filter results…')" @@ -297,17 +298,17 @@ export default { stacked="sm" tbody-tr-class="table-row mb-4" > - <template v-slot:head(error)> + <template #head(error)> <div class="d-none d-sm-block">{{ __('Open errors') }}</div> </template> - <template v-slot:head(events)="data"> + <template #head(events)="data"> <div class="text-sm-right">{{ data.label }}</div> </template> - <template v-slot:head(users)="data"> + <template #head(users)="data"> <div class="text-sm-right">{{ data.label }}</div> </template> - <template v-slot:error="errors"> + <template #cell(error)="errors"> <div class="d-flex flex-column"> <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)"> <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> @@ -317,40 +318,34 @@ export default { </span> </div> </template> - <template v-slot:events="errors"> + <template #cell(events)="errors"> <div class="text-right">{{ errors.item.count }}</div> </template> - <template v-slot:users="errors"> + <template #cell(users)="errors"> <div class="text-right">{{ errors.item.userCount }}</div> </template> - <template v-slot:lastSeen="errors"> + <template #cell(lastSeen)="errors"> <div class="text-md-left text-right"> <time-ago :time="errors.item.lastSeen" class="text-secondary" /> </div> </template> - <template v-slot:ignore="errors"> - <gl-button - ref="ignoreError" - v-gl-tooltip.hover - :title="__('Ignore')" - @click="updateIssueStatus(errors.item.id, 'ignored')" - > - <gl-icon name="eye-slash" :size="12" /> - </gl-button> - </template> - <template v-slot:resolved="errors"> - <gl-button - ref="resolveError" - v-gl-tooltip - :title="__('Resolve')" - @click="updateIssueStatus(errors.item.id, 'resolved')" - > - <gl-icon name="check-circle" :size="12" /> - </gl-button> + <template #cell(status)="errors"> + <gl-button-group> + <gl-button + v-for="button in $options.statusButtons" + :key="button.status" + :ref="button.title.toLowerCase() + 'Error'" + v-gl-tooltip.hover + :title="button.title" + @click="updateIssueStatus(errors.item.id, button.status)" + > + <gl-icon :name="button.icon" :size="12" /> + </gl-button> + </gl-button-group> </template> - <template v-slot:details="errors"> + <template #cell(details)="errors"> <gl-button :href="getDetailsLink(errors.item.id)" variant="outline-info" @@ -359,7 +354,7 @@ export default { {{ __('More details') }} </gl-button> </template> - <template v-slot:empty> + <template #empty> {{ __('No errors to display.') }} <gl-link class="js-try-again" @click="restartPolling"> {{ __('Check again') }} diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 4e63e167260..8db0b1c5da0 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { GlTooltip } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -62,7 +62,7 @@ export default { ? sprintf( __(`%{spanStart}in%{spanEnd} %{errorFn}`), { - errorFn: `<strong>${_.escape(this.errorFn)}</strong>`, + errorFn: `<strong>${esc(this.errorFn)}</strong>`, spanStart: `<span class="text-tertiary">`, spanEnd: `</span>`, }, diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index c18298dec4f..55ab362f805 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -8,37 +8,35 @@ import csrf from '~/lib/utils/csrf'; Vue.use(VueApollo); export default () => { + const selector = '#js-error_details'; + + const domEl = document.querySelector(selector); + const { + issueId, + projectPath, + issueUpdatePath, + issueStackTracePath, + projectIssuesPath, + } = domEl.dataset; + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); // eslint-disable-next-line no-new new Vue({ - el: '#js-error_details', + el: selector, apolloProvider, components: { ErrorDetails, }, store, render(createElement) { - const domEl = document.querySelector(this.$options.el); - const { - issueId, - projectPath, - listPath, - issueUpdatePath, - issueDetailsPath, - issueStackTracePath, - projectIssuesPath, - } = domEl.dataset; - return createElement('error-details', { props: { issueId, projectPath, - listPath, issueUpdatePath, - issueDetailsPath, issueStackTracePath, projectIssuesPath, csrfToken: csrf.token, diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js index 8f3700249da..cb656a9ef13 100644 --- a/app/assets/javascripts/error_tracking/list.js +++ b/app/assets/javascripts/error_tracking/list.js @@ -4,27 +4,29 @@ import store from './store'; import ErrorTrackingList from './components/error_tracking_list.vue'; export default () => { + const selector = '#js-error_tracking'; + + const domEl = document.querySelector(selector); + const { + indexPath, + enableErrorTrackingLink, + illustrationPath, + projectPath, + listPath, + } = domEl.dataset; + let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; + + errorTrackingEnabled = parseBoolean(errorTrackingEnabled); + userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking); + // eslint-disable-next-line no-new new Vue({ - el: '#js-error_tracking', + el: selector, components: { ErrorTrackingList, }, store, render(createElement) { - const domEl = document.querySelector(this.$options.el); - const { - indexPath, - enableErrorTrackingLink, - illustrationPath, - projectPath, - listPath, - } = domEl.dataset; - let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; - - errorTrackingEnabled = parseBoolean(errorTrackingEnabled); - userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking); - return createElement('error-tracking-list', { props: { indexPath, diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index 625ce3030d9..fa579c94257 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,20 +1,29 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { project(fullPath: $fullPath) { - sentryDetailedError(id: $errorId) { - id - sentryId - title - userCount - count - firstSeen - lastSeen - message - culprit - externalUrl - firstReleaseShortVersion - lastReleaseShortVersion - gitlabCommit - gitlabCommitPath + sentryErrors { + detailedError(id: $errorId) { + id + sentryId + title + userCount + count + status + firstSeen + lastSeen + message + culprit + tags { + level + logger + } + externalUrl + externalBaseUrl + firstReleaseShortVersion + lastReleaseShortVersion + gitlabCommit + gitlabCommitPath + gitlabIssuePath + } } } } diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index bb8b039b5df..8f6f404ef8a 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -4,16 +4,35 @@ import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -export function updateStatus({ commit }, { endpoint, redirectUrl, status }) { - const type = - status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS; - commit(type, true); +export const setStatus = ({ commit }, status) => { + commit(types.SET_ERROR_STATUS, status.toLowerCase()); +}; - return service +export const updateStatus = ({ commit }, { endpoint, redirectUrl, status }) => + service .updateErrorStatus(endpoint, status) - .then(() => visitUrl(redirectUrl)) - .catch(() => createFlash(__('Failed to update issue status'))) - .finally(() => commit(type, false)); -} + .then(resp => { + commit(types.SET_ERROR_STATUS, status); + if (redirectUrl) visitUrl(redirectUrl); + + return resp.data.result; + }) + .catch(() => createFlash(__('Failed to update issue status'))); + +export const updateResolveStatus = ({ commit, dispatch }, params) => { + commit(types.SET_UPDATING_RESOLVE_STATUS, true); + + return dispatch('updateStatus', params).finally(() => { + commit(types.SET_UPDATING_RESOLVE_STATUS, false); + }); +}; + +export const updateIgnoreStatus = ({ commit, dispatch }, params) => { + commit(types.SET_UPDATING_IGNORE_STATUS, true); + + return dispatch('updateStatus', params).finally(() => { + commit(types.SET_UPDATING_IGNORE_STATUS, false); + }); +}; export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js index 0390bca7175..5914a79f092 100644 --- a/app/assets/javascripts/error_tracking/store/details/actions.js +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -5,37 +5,11 @@ import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; let stackTracePoll; -let detailPoll; const stopPolling = poll => { if (poll) poll.stop(); }; -export function startPollingDetails({ commit }, endpoint) { - detailPoll = new Poll({ - resource: service, - method: 'getSentryData', - data: { endpoint }, - successCallback: ({ data }) => { - if (!data) { - detailPoll.restart(); - return; - } - - commit(types.SET_ERROR, data.error); - commit(types.SET_LOADING, false); - - stopPolling(detailPoll); - }, - errorCallback: () => { - commit(types.SET_LOADING, false); - createFlash(__('Failed to load error details from Sentry.')); - }, - }); - - detailPoll.makeRequest(); -} - export function startPollingStacktrace({ commit }, endpoint) { stackTracePoll = new Poll({ resource: service, @@ -43,7 +17,6 @@ export function startPollingStacktrace({ commit }, endpoint) { data: { endpoint }, successCallback: ({ data }) => { if (!data) { - stackTracePoll.restart(); return; } commit(types.SET_STACKTRACE_DATA, data.error); diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js index a2592253a2d..0dd49e727e6 100644 --- a/app/assets/javascripts/error_tracking/store/details/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js @@ -1,4 +1,2 @@ -export const SET_ERROR = 'SET_ERRORS'; -export const SET_LOADING = 'SET_LOADING'; export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE'; export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA'; diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js index 6f4720444e0..b2bde96c6a9 100644 --- a/app/assets/javascripts/error_tracking/store/details/mutations.js +++ b/app/assets/javascripts/error_tracking/store/details/mutations.js @@ -1,12 +1,6 @@ import * as types from './mutation_types'; export default { - [types.SET_ERROR](state, data) { - state.error = data; - }, - [types.SET_LOADING](state, loading) { - state.loading = loading; - }, [types.SET_LOADING_STACKTRACE](state, data) { state.loadingStacktrace = data; }, diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js index 52b0297607d..4a6bafe3114 100644 --- a/app/assets/javascripts/error_tracking/store/details/state.js +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -1,8 +1,7 @@ export default () => ({ - error: {}, stacktraceData: {}, - loading: true, loadingStacktrace: true, updatingResolveStatus: false, updatingIgnoreStatus: false, + errorStatus: '', }); diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index d96ac7f524e..6f8573c0f4d 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -100,4 +100,8 @@ export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => { dispatch('startPolling'); }; +export const removeIgnoredResolvedErrors = ({ commit }, error) => { + commit(types.REMOVE_IGNORED_RESOLVED_ERRORS, error); +}; + export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index c3468b7eabd..23495cbf01d 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js @@ -9,3 +9,4 @@ export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_SORT_FIELD = 'SET_SORT_FIELD'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const SET_CURSOR = 'SET_CURSOR'; +export const REMOVE_IGNORED_RESOLVED_ERRORS = 'REMOVE_IGNORED_RESOLVED_ERRORS'; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index dd5cde0576a..38d156263fb 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -59,4 +59,7 @@ export default { [types.SET_ENDPOINT](state, endpoint) { state.endpoint = endpoint; }, + [types.REMOVE_IGNORED_RESOLVED_ERRORS](state, error) { + state.errors = state.errors.filter(err => err.id !== error); + }, }; diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js index 30aebacbedd..a7ac6ab2e60 100644 --- a/app/assets/javascripts/error_tracking/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/mutation_types.js @@ -1,2 +1,3 @@ export const SET_UPDATING_RESOLVE_STATUS = 'SET_UPDATING_RESOLVE_STATUS'; export const SET_UPDATING_IGNORE_STATUS = 'SET_UPDATING_IGNORE_STATUS'; +export const SET_ERROR_STATUS = 'SET_ERROR_STATUS'; diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js index c7a7e46df40..8f2d9bcbe85 100644 --- a/app/assets/javascripts/error_tracking/store/mutations.js +++ b/app/assets/javascripts/error_tracking/store/mutations.js @@ -7,4 +7,7 @@ export default { [types.SET_UPDATING_RESOLVE_STATUS](state, updating) { state.updatingResolveStatus = updating; }, + [types.SET_ERROR_STATUS](state, status) { + state.errorStatus = status; + }, }; diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js index d77e5f15469..e27fe9c079e 100644 --- a/app/assets/javascripts/error_tracking_settings/store/getters.js +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { isMatch } from 'lodash'; import { __, s__, sprintf } from '~/locale'; import { getDisplayName } from '../utils'; @@ -7,7 +7,7 @@ export const hasProjects = state => Boolean(state.projects) && state.projects.le export const isProjectInvalid = (state, getters) => Boolean(state.selectedProject) && getters.hasProjects && - !state.projects.some(project => _.isMatch(state.selectedProject, project)); + !state.projects.some(project => isMatch(state.selectedProject, project)); export const dropdownLabel = (state, getters) => { if (state.selectedProject !== null) { diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js index 133f25264b9..e1986eb694b 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutations.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { pick } from 'lodash'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import * as types from './mutation_types'; import { projectKeys } from '../utils'; @@ -12,7 +12,7 @@ export default { .map(convertObjectPropsToCamelCase) // The `pick` strips out extra properties returned from Sentry. // Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject` - .map(project => _.pick(project, projectKeys)); + .map(project => pick(project, projectKeys)); }, [types.RESET_CONNECT](state) { state.connectSuccessful = false; @@ -29,10 +29,7 @@ export default { state.operationsSettingsEndpoint = operationsSettingsEndpoint; if (project) { - state.selectedProject = _.pick( - convertObjectPropsToCamelCase(JSON.parse(project)), - projectKeys, - ); + state.selectedProject = pick(convertObjectPropsToCamelCase(JSON.parse(project)), projectKeys); } }, [types.UPDATE_API_HOST](state, apiHost) { diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js index 6613e04ee0e..450e8728121 100644 --- a/app/assets/javascripts/error_tracking_settings/utils.js +++ b/app/assets/javascripts/error_tracking_settings/utils.js @@ -13,6 +13,6 @@ export const transformFrontendSettings = ({ apiHost, enabled, token, selectedPro return { api_host: apiHost || null, enabled, token: token || null, project }; }; -export const getDisplayName = project => `${project.organizationName} | ${project.name}`; +export const getDisplayName = project => `${project.organizationName} | ${project.slug}`; export default () => {}; diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index fa2609a3176..e2909333d74 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -59,21 +59,25 @@ export default { </script> <template> <div> - <div v-if="!isLocalStorageAvailable" class="dropdown-info-note"> + <div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note"> {{ __('This feature requires local storage to be enabled') }} </div> <ul v-else-if="hasItems"> - <li v-for="(item, index) in processedItems" :key="`processed-items-${index}`"> + <li + v-for="(item, index) in processedItems" + ref="dropdownItem" + :key="`processed-items-${index}`" + > <button type="button" - class="filtered-search-history-dropdown-item" + class="filtered-search-history-dropdown-item js-dropdown-button" @click="onItemActivated(item.text)" > <span> <span v-for="(token, tokenIndex) in item.tokens" :key="`dropdown-token-${tokenIndex}`" - class="filtered-search-history-dropdown-token" + class="filtered-search-history-dropdown-token js-dropdown-token" > <span class="name">{{ token.prefix }}</span> <span class="name">{{ token.operator }}</span> @@ -88,6 +92,7 @@ export default { <li class="divider"></li> <li> <button + ref="clearButton" type="button" class="filtered-search-history-clear-button" @click="onRequestClearRecentSearches($event)" @@ -96,6 +101,8 @@ export default { </button> </li> </ul> - <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div> + <div v-else ref="dropdownNote" class="dropdown-info-note"> + {{ __("You don't have any recent searches") }} + </div> </div> </template> diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index bd4fda29609..d9794e326f8 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -45,13 +45,13 @@ export default class DropdownOperator extends FilteredSearchDropdown { tag: 'equal', type: 'string', title: '=', - help: __('Is'), + help: __('is'), }, { tag: 'not-equal', type: 'string', title: '!=', - help: __('Is not'), + help: __('is not'), }, ]; this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index 72565c2ca13..2b6e1f25dc6 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -12,7 +12,7 @@ export default class FilteredSearchDropdown { this.filter = filter; this.dropdown = dropdown; this.loadingTemplate = `<div class="filter-dropdown-loading"> - <i class="fa fa-spinner fa-spin"></i> + <span class="spinner"></span> </div>`; this.bindEvents(); } diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 2c3320b5e79..4d62ec6e385 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,10 +1,17 @@ import _ from 'underscore'; import { spriteIcon } from './lib/utils/common_utils'; +const FLASH_TYPES = { + ALERT: 'alert', + NOTICE: 'notice', + SUCCESS: 'success', + WARNING: 'warning', +}; + const hideFlash = (flashEl, fadeTransition = true) => { if (fadeTransition) { Object.assign(flashEl.style, { - transition: 'opacity .3s', + transition: 'opacity 0.15s', opacity: '0', }); } @@ -59,7 +66,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { * additional action or link on banner next to message * * @param {String} message Flash message text - * @param {String} type Type of Flash, it can be `notice` or `alert` (default) + * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) * @param {Object} parent Reference to parent element under which Flash needs to appear * @param {Object} actonConfig Map of config to show action on banner * @param {String} href URL to which action config should point to (default: '#') @@ -69,7 +76,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { */ const createFlash = function createFlash( message, - type = 'alert', + type = FLASH_TYPES.ALERT, parent = document, actionConfig = null, fadeTransition = true, @@ -102,5 +109,12 @@ const createFlash = function createFlash( return flashContainer; }; -export { createFlash as default, createFlashEl, createAction, hideFlash, removeFlashClickListener }; +export { + createFlash as default, + createFlashEl, + createAction, + hideFlash, + removeFlashClickListener, + FLASH_TYPES, +}; window.Flash = createFlash; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 92c3bcb5012..6188d41ae96 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -54,8 +54,8 @@ export default { <template> <li class="frequent-items-list-item-container"> <a :href="webUrl" class="clearfix"> - <div class="frequent-items-item-avatar-container"> - <img v-if="hasAvatar" :src="avatarUrl" class="avatar rect-avatar s32" /> + <div class="frequent-items-item-avatar-container avatar-container rect-avatar s32"> + <img v-if="hasAvatar" :src="avatarUrl" class="avatar s32" /> <identicon v-else :entity-id="itemId" diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index de69daf5c22..fa2e3f94f87 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,7 @@ import $ from 'jquery'; -import 'at.js'; +import '@gitlab/at.js'; import _ from 'underscore'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; @@ -53,8 +54,8 @@ export const defaultAutocompleteConfig = { }; class GfmAutoComplete { - constructor(dataSources) { - this.dataSources = dataSources || {}; + constructor(dataSources = {}) { + this.dataSources = dataSources; this.cachedData = {}; this.isLoadingData = {}; } @@ -199,6 +200,16 @@ class GfmAutoComplete { } setupMembers($input) { + const fetchData = this.fetchData.bind(this); + const MEMBER_COMMAND = { + ASSIGN: '/assign', + UNASSIGN: '/unassign', + REASSIGN: '/reassign', + CC: '/cc', + }; + let assignees = []; + let command = ''; + // Team Members $input.atwho({ at: '@', @@ -225,6 +236,48 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), beforeSave: membersBeforeSave, + matcher(flag, subtext) { + const subtextNodes = subtext + .split(/\n+/g) + .pop() + .split(GfmAutoComplete.regexSubtext); + + // Check if @ is followed by '/assign', '/reassign', '/unassign' or '/cc' commands. + command = subtextNodes.find(node => { + if (Object.values(MEMBER_COMMAND).includes(node)) { + return node; + } + return null; + }); + + // Cache assignees list for easier filtering later + assignees = SidebarMediator.singleton?.store?.assignees?.map( + assignee => `${assignee.username} ${assignee.name}`, + ); + + const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); + return match && match.length ? match[1] : null; + }, + filter(query, data, searchKey) { + if (GfmAutoComplete.isLoading(data)) { + fetchData(this.$inputor, this.at); + return data; + } + + if (data === GfmAutoComplete.defaultLoadingData) { + return $.fn.atwho.default.callbacks.filter(query, data, searchKey); + } + + if (command === MEMBER_COMMAND.ASSIGN) { + // Only include members which are not assigned to Issuable currently + return data.filter(member => !assignees.includes(member.search)); + } else if (command === MEMBER_COMMAND.UNASSIGN) { + // Only include members which are assigned to Issuable currently + return data.filter(member => assignees.includes(member.search)); + } + + return data; + }, }, }); } @@ -666,7 +719,7 @@ GfmAutoComplete.Milestones = { }; GfmAutoComplete.Loading = { template: - '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>', + '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>', }; export default GfmAutoComplete; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 65d05887453..918276ce329 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, one-var, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */ +/* eslint-disable max-classes-per-file, one-var, consistent-return */ import $ from 'jquery'; import _ from 'underscore'; @@ -32,121 +32,124 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; -function GitLabDropdownInput(input, options) { - const _this = this; - this.input = input; - this.options = options; - this.fieldName = this.options.fieldName || 'field-name'; - const $inputContainer = this.input.parent(); - const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', e => { - // Clear click - e.preventDefault(); - e.stopPropagation(); - return this.input - .val('') - .trigger('input') - .focus(); - }); - - this.input - .on('keydown', e => { - const keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', e => { - let val = e.currentTarget.value || _this.options.inputFieldName; - val = val - .split(' ') - .join('-') // replaces space with dash - .replace(/[^a-zA-Z0-9 -]/g, '') - .toLowerCase() // replace non alphanumeric - .replace(/(-)\1+/g, '-'); // replace repeated dashes - _this.cb(_this.options.fieldName, val, {}, true); - _this.input - .closest('.dropdown') - .find('.dropdown-toggle-text') - .text(val); +class GitLabDropdownInput { + constructor(input, options) { + this.input = input; + this.options = options; + this.fieldName = this.options.fieldName || 'field-name'; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); }); -} -GitLabDropdownInput.prototype.onInput = function(cb) { - this.cb = cb; -}; + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', e => { + let val = e.currentTarget.value || this.options.inputFieldName; + val = val + .split(' ') + .join('-') // replaces space with dash + .replace(/[^a-zA-Z0-9 -]/g, '') + .toLowerCase() // replace non alphanumeric + .replace(/(-)\1+/g, '-'); // replace repeated dashes + this.cb(this.options.fieldName, val, {}, true); + this.input + .closest('.dropdown') + .find('.dropdown-toggle-text') + .text(val); + }); + } -function GitLabDropdownFilter(input, options) { - let ref, timeout; - this.input = input; - this.options = options; - this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; - const $inputContainer = this.input.parent(); - const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', e => { - // Clear click - e.preventDefault(); - e.stopPropagation(); - return this.input - .val('') - .trigger('input') - .focus(); - }); - // Key events - timeout = ''; - this.input - .on('keydown', e => { - const keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', () => { - if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.removeClass(HAS_VALUE_CLASS); - } - // Only filter asynchronously only if option remote is set - if (this.options.remote) { - clearTimeout(timeout); - return (timeout = setTimeout(() => { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), data => { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }); - }, 250)); - } else { - return this.filter(this.input.val()); - } - }); + onInput(cb) { + this.cb = cb; + } } -GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { - return BLUR_KEYCODES.indexOf(keyCode) !== -1; -}; +class GitLabDropdownFilter { + constructor(input, options) { + let ref, timeout; + this.input = input; + this.options = options; + // eslint-disable-next-line no-cond-assign + this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); + }); + // Key events + timeout = ''; + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', () => { + if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + // Only filter asynchronously only if option remote is set + if (this.options.remote) { + clearTimeout(timeout); + // eslint-disable-next-line no-return-assign + return (timeout = setTimeout(() => { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), data => { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }); + }, 250)); + } + return this.filter(this.input.val()); + }); + } -GitLabDropdownFilter.prototype.filter = function(search_text) { - let elements, group, key, results, tmp; - if (this.options.onFilter) { - this.options.onFilter(search_text); - } - const data = this.options.data(); - if (data != null && !this.options.filterByText) { - results = data; - if (search_text !== '') { - // When data is an array of objects therefore [object Array] e.g. - // [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ] - if (_.isArray(data)) { - results = fuzzaldrinPlus.filter(data, search_text, { - key: this.options.keys, - }); - } else { + static shouldBlur(keyCode) { + return BLUR_KEYCODES.indexOf(keyCode) !== -1; + } + + filter(searchText) { + let group, results, tmp; + if (this.options.onFilter) { + this.options.onFilter(searchText); + } + const data = this.options.data(); + if (data != null && !this.options.filterByText) { + results = data; + if (searchText !== '') { + // When data is an array of objects therefore [object Array] e.g. + // [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ] + if (_.isArray(data)) { + results = fuzzaldrinPlus.filter(data, searchText, { + key: this.options.keys, + }); + } // If data is grouped therefore an [object Object]. e.g. // { // groupName1: [ @@ -158,33 +161,32 @@ GitLabDropdownFilter.prototype.filter = function(search_text) { // { prop: 'def' } // ] // } - if (isObject(data)) { + else if (isObject(data)) { results = {}; - for (key in data) { + Object.keys(data).forEach(key => { group = data[key]; - tmp = fuzzaldrinPlus.filter(group, search_text, { + tmp = fuzzaldrinPlus.filter(group, searchText, { key: this.options.keys, }); if (tmp.length) { results[key] = tmp.map(item => item); } - } + }); } } + return this.options.callback(results); } - return this.options.callback(results); - } else { - elements = this.options.elements(); - if (search_text) { + const elements = this.options.elements(); + if (searchText) { + // eslint-disable-next-line func-names elements.each(function() { const $el = $(this); - const matches = fuzzaldrinPlus.match($el.text().trim(), search_text); + const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); if (!$el.is('.dropdown-header')) { if (matches.length) { return $el.show().removeClass('option-hidden'); - } else { - return $el.hide().addClass('option-hidden'); } + return $el.hide().addClass('option-hidden'); } }); } else { @@ -196,235 +198,240 @@ GitLabDropdownFilter.prototype.filter = function(search_text) { .find('.dropdown-menu-empty-item') .toggleClass('hidden', elements.is(':visible')); } -}; - -function GitLabDropdownRemote(dataEndpoint, options) { - this.dataEndpoint = dataEndpoint; - this.options = options; } -GitLabDropdownRemote.prototype.execute = function() { - if (typeof this.dataEndpoint === 'string') { - return this.fetchData(); - } else if (typeof this.dataEndpoint === 'function') { +class GitLabDropdownRemote { + constructor(dataEndpoint, options) { + this.dataEndpoint = dataEndpoint; + this.options = options; + } + + execute() { + if (typeof this.dataEndpoint === 'string') { + return this.fetchData(); + } else if (typeof this.dataEndpoint === 'function') { + if (this.options.beforeSend) { + this.options.beforeSend(); + } + return this.dataEndpoint('', data => { + // Fetch the data by calling the data function + if (this.options.success) { + this.options.success(data); + } + if (this.options.beforeSend) { + return this.options.beforeSend(); + } + }); + } + } + + fetchData() { if (this.options.beforeSend) { this.options.beforeSend(); } - return this.dataEndpoint('', data => { - // Fetch the data by calling the data function + + // Fetch the data through ajax if the data is a string + return axios.get(this.dataEndpoint).then(({ data }) => { if (this.options.success) { - this.options.success(data); - } - if (this.options.beforeSend) { - return this.options.beforeSend(); + return this.options.success(data); } }); } -}; - -GitLabDropdownRemote.prototype.fetchData = function() { - if (this.options.beforeSend) { - this.options.beforeSend(); - } +} - // Fetch the data through ajax if the data is a string - return axios.get(this.dataEndpoint).then(({ data }) => { - if (this.options.success) { - return this.options.success(data); +class GitLabDropdown { + constructor(el1, options) { + let selector, self; + this.el = el1; + this.options = options; + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); + self = this; + selector = $(this.el).data('target'); + this.dropdown = selector != null ? $(selector) : $(this.el).parent(); + // Set Defaults + this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); + this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); + this.highlight = Boolean(this.options.highlight); + this.icon = Boolean(this.options.icon); + this.filterInputBlur = + this.options.filterInputBlur != null ? this.options.filterInputBlur : true; + // If no input is passed create a default one + self = this; + // If selector was passed + if (_.isString(this.filterInput)) { + this.filterInput = this.getElement(this.filterInput); } - }); -}; - -function GitLabDropdown(el1, options) { - let selector, self; - this.el = el1; - this.options = options; - this.updateLabel = this.updateLabel.bind(this); - this.hidden = this.hidden.bind(this); - this.opened = this.opened.bind(this); - this.shouldPropagate = this.shouldPropagate.bind(this); - self = this; - selector = $(this.el).data('target'); - this.dropdown = selector != null ? $(selector) : $(this.el).parent(); - // Set Defaults - this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); - this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); - this.highlight = Boolean(this.options.highlight); - this.icon = Boolean(this.options.icon); - this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true; - // If no input is passed create a default one - self = this; - // If selector was passed - if (_.isString(this.filterInput)) { - this.filterInput = this.getElement(this.filterInput); - } - const searchFields = this.options.search ? this.options.search.fields : []; - if (this.options.data) { - // If we provided data - // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { - this.fullData = this.options.data; - currentIndex = -1; - this.parseData(this.options.data); - this.focusTextInput(); - } else { - this.remote = new GitLabDropdownRemote(this.options.data, { - dataType: this.options.dataType, - beforeSend: this.toggleLoading.bind(this), - success: data => { - this.fullData = data; - this.parseData(this.fullData); - this.focusTextInput(); - - // Update dropdown position since remote data may have changed dropdown size - this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); - - if ( - this.options.filterable && - this.filter && - this.filter.input && - this.filter.input.val() && - this.filter.input.val().trim() !== '' - ) { - return this.filter.input.trigger('input'); - } - }, - instance: this, - }); + const searchFields = this.options.search ? this.options.search.fields : []; + if (this.options.data) { + // If we provided data + // data could be an array of objects or a group of arrays + if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + this.fullData = this.options.data; + currentIndex = -1; + this.parseData(this.options.data); + this.focusTextInput(); + } else { + this.remote = new GitLabDropdownRemote(this.options.data, { + dataType: this.options.dataType, + beforeSend: this.toggleLoading.bind(this), + success: data => { + this.fullData = data; + this.parseData(this.fullData); + this.focusTextInput(); + + // Update dropdown position since remote data may have changed dropdown size + this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); + + if ( + this.options.filterable && + this.filter && + this.filter.input && + this.filter.input.val() && + this.filter.input.val().trim() !== '' + ) { + return this.filter.input.trigger('input'); + } + }, + instance: this, + }); + } } - } - if (this.noFilterInput.length) { - this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); - this.plainInput.onInput(this.addInput.bind(this)); - } - // Init filterable - if (this.options.filterable) { - this.filter = new GitLabDropdownFilter(this.filterInput, { - elIsInput: $(this.el).is('input'), - filterInputBlur: this.filterInputBlur, - filterByText: this.options.filterByText, - onFilter: this.options.onFilter, - remote: this.options.filterRemote, - query: this.options.data, - keys: searchFields, - instance: this, - elements: () => { - selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - return $(selector, this.dropdown); - }, - data: () => this.fullData, - callback: data => { - this.parseData(data); - if (this.filterInput.val() !== '') { - selector = SELECTABLE_CLASSES; + if (this.noFilterInput.length) { + this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); + this.plainInput.onInput(this.addInput.bind(this)); + } + // Init filterable + if (this.options.filterable) { + this.filter = new GitLabDropdownFilter(this.filterInput, { + elIsInput: $(this.el).is('input'), + filterInputBlur: this.filterInputBlur, + filterByText: this.options.filterByText, + onFilter: this.options.onFilter, + remote: this.options.filterRemote, + query: this.options.data, + keys: searchFields, + instance: this, + elements: () => { + selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; if (this.dropdown.find('.dropdown-toggle-page').length) { selector = `.dropdown-page-one ${selector}`; } - if ($(this.el).is('input')) { - currentIndex = -1; - } else { - $(selector, this.dropdown) - .first() - .find('a') - .addClass('is-focused'); - currentIndex = 0; + return $(selector, this.dropdown); + }, + data: () => this.fullData, + callback: data => { + this.parseData(data); + if (this.filterInput.val() !== '') { + selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + if ($(this.el).is('input')) { + currentIndex = -1; + } else { + $(selector, this.dropdown) + .first() + .find('a') + .addClass('is-focused'); + currentIndex = 0; + } } - } - }, - }); - } - // Event listeners - this.dropdown.on('shown.bs.dropdown', this.opened); - this.dropdown.on('hidden.bs.dropdown', this.hidden); - $(this.el).on('update.label', this.updateLabel); - this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); - this.dropdown.on('keyup', e => { - // Escape key - if (e.which === 27) { - return $('.dropdown-menu-close', this.dropdown).trigger('click'); + }, + }); } - }); - this.dropdown.on('blur', 'a', e => { - let $dropdownMenu, $relatedTarget; - if (e.relatedTarget != null) { - $relatedTarget = $(e.relatedTarget); - $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); - if ($dropdownMenu.length === 0) { - return this.dropdown.removeClass('show'); + // Event listeners + this.dropdown.on('shown.bs.dropdown', this.opened); + this.dropdown.on('hidden.bs.dropdown', this.hidden); + $(this.el).on('update.label', this.updateLabel); + this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); + this.dropdown.on('keyup', e => { + // Escape key + if (e.which === 27) { + return $('.dropdown-menu-close', this.dropdown).trigger('click'); + } + }); + this.dropdown.on('blur', 'a', e => { + let $dropdownMenu, $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return this.dropdown.removeClass('show'); + } } - } - }); - if (this.dropdown.find('.dropdown-toggle-page').length) { - this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => { - e.preventDefault(); - e.stopPropagation(); - return this.togglePage(); }); - } - if (this.options.selectable) { - selector = '.dropdown-content a'; if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one .dropdown-content a'; - } - this.dropdown.on('click', selector, e => { - const $el = $(e.currentTarget); - const selected = self.rowClicked($el); - const selectedObj = selected ? selected[0] : null; - const isMarking = selected ? selected[1] : null; - if (this.options.clicked) { - this.options.clicked.call(this, { - selectedObj, - $el, - e, - isMarking, - }); + this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => { + e.preventDefault(); + e.stopPropagation(); + return this.togglePage(); + }); + } + if (this.options.selectable) { + selector = '.dropdown-content a'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one .dropdown-content a'; } + this.dropdown.on('click', selector, e => { + const $el = $(e.currentTarget); + const selected = self.rowClicked($el); + const selectedObj = selected ? selected[0] : null; + const isMarking = selected ? selected[1] : null; + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); + } - // Update label right after all modifications in dropdown has been done - if (this.options.toggleLabel) { - this.updateLabel(selectedObj, $el, this); - } + // Update label right after all modifications in dropdown has been done + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); + } - $el.trigger('blur'); - }); + $el.trigger('blur'); + }); + } } -} -// Finds an element inside wrapper element -GitLabDropdown.prototype.getElement = function(selector) { - return this.dropdown.find(selector); -}; + // Finds an element inside wrapper element + getElement(selector) { + return this.dropdown.find(selector); + } -GitLabDropdown.prototype.toggleLoading = function() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); -}; + toggleLoading() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + } -GitLabDropdown.prototype.togglePage = function() { - const menu = $('.dropdown-menu', this.dropdown); - if (menu.hasClass(PAGE_TWO_CLASS)) { - if (this.remote) { - this.remote.execute(); + togglePage() { + const menu = $('.dropdown-menu', this.dropdown); + if (menu.hasClass(PAGE_TWO_CLASS)) { + if (this.remote) { + this.remote.execute(); + } } + menu.toggleClass(PAGE_TWO_CLASS); + // Focus first visible input on active page + return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); } - menu.toggleClass(PAGE_TWO_CLASS); - // Focus first visible input on active page - return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); -}; -GitLabDropdown.prototype.parseData = function(data) { - let groupData, html, name; - this.renderedData = data; - if (this.options.filterable && data.length === 0) { - // render no matching results - html = [this.noResults()]; - } else { + parseData(data) { + let groupData, html; + this.renderedData = data; + if (this.options.filterable && data.length === 0) { + // render no matching results + html = [this.noResults()]; + } // Handle array groups - if (isObject(data)) { + else if (isObject(data)) { html = []; - for (name in data) { + + Object.keys(data).forEach(name => { groupData = data[name]; html.push( this.renderItem( @@ -436,461 +443,455 @@ GitLabDropdown.prototype.parseData = function(data) { ), ); this.renderData(groupData, name).map(item => html.push(item)); - } + }); } else { // Render each row html = this.renderData(data); } - } - // Render the full menu - const full_html = this.renderMenu(html); - return this.appendMenu(full_html); -}; - -GitLabDropdown.prototype.renderData = function(data, group) { - return data.map((obj, index) => this.renderItem(obj, group || false, index)); -}; - -GitLabDropdown.prototype.shouldPropagate = function(e) { - let $target; - if (this.options.multiSelect || this.options.shouldPropagate === false) { - $target = $(e.target); - if ( - $target && - !$target.hasClass('dropdown-menu-close') && - !$target.hasClass('dropdown-menu-close-icon') && - !$target.data('isLink') - ) { - e.stopPropagation(); - - // This prevents automatic scrolling to the top - if ($target.closest('a').length) { - return false; + // Render the full menu + const fullHtml = this.renderMenu(html); + return this.appendMenu(fullHtml); + } + + renderData(data, group) { + return data.map((obj, index) => this.renderItem(obj, group || false, index)); + } + + shouldPropagate(e) { + let $target; + if (this.options.multiSelect || this.options.shouldPropagate === false) { + $target = $(e.target); + if ( + $target && + !$target.hasClass('dropdown-menu-close') && + !$target.hasClass('dropdown-menu-close-icon') && + !$target.data('isLink') + ) { + e.stopPropagation(); + + // This prevents automatic scrolling to the top + if ($target.closest('a').length) { + return false; + } } - } - return true; + return true; + } } -}; - -GitLabDropdown.prototype.filteredFullData = function() { - return this.fullData.filter( - r => - typeof r === 'object' && - !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && - !Object.prototype.hasOwnProperty.call(r, 'header'), - ); -}; -GitLabDropdown.prototype.opened = function(e) { - this.resetRows(); - this.addArrowKeyEvent(); - - const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); - const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); - const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); - const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); - - // Makes indeterminate items effective - if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { - this.parseData(this.fullData); - } - - // Process the data to make sure rendered data - // matches the correct layout - const inputValue = this.filterInput.val(); - if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { - this.options.processData.call( - this.options, - inputValue, - this.filteredFullData(), - this.parseData.bind(this), + filteredFullData() { + return this.fullData.filter( + r => + typeof r === 'object' && + !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && + !Object.prototype.hasOwnProperty.call(r, 'header'), ); } - const contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === '') { - this.remote.execute(); - } else { - this.focusTextInput(); - } + opened(e) { + this.resetRows(); + this.addArrowKeyEvent(); - if (this.options.showMenuAbove) { - this.positionMenuAbove(); - } + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); - if (this.options.opened) { - if (this.options.preserveContext) { - this.options.opened(e); - } else { - this.options.opened.call(this, e); + // Makes indeterminate items effective + if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { + this.parseData(this.fullData); } - } - return this.dropdown.trigger('shown.gl.dropdown'); -}; + // Process the data to make sure rendered data + // matches the correct layout + const inputValue = this.filterInput.val(); + if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { + this.options.processData.call( + this.options, + inputValue, + this.filteredFullData(), + this.parseData.bind(this), + ); + } -GitLabDropdown.prototype.positionMenuAbove = function() { - const $menu = this.dropdown.find('.dropdown-menu'); + const contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === '') { + this.remote.execute(); + } else { + this.focusTextInput(); + } - $menu.addClass('dropdown-open-top'); - $menu.css('top', 'initial'); - $menu.css('bottom', '100%'); -}; + if (this.options.showMenuAbove) { + this.positionMenuAbove(); + } + + if (this.options.opened) { + if (this.options.preserveContext) { + this.options.opened(e); + } else { + this.options.opened.call(this, e); + } + } -GitLabDropdown.prototype.hidden = function(e) { - this.resetRows(); - this.removeArrowKeyEvent(); - const $input = this.dropdown.find('.dropdown-input-field'); - if (this.options.filterable) { - $input.blur(); + return this.dropdown.trigger('shown.gl.dropdown'); } - if (this.dropdown.find('.dropdown-toggle-page').length) { - $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + + positionMenuAbove() { + const $menu = this.dropdown.find('.dropdown-menu'); + + $menu.addClass('dropdown-open-top'); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); } - if (this.options.hidden) { - this.options.hidden.call(this, e); + + hidden(e) { + this.resetRows(); + this.removeArrowKeyEvent(); + const $input = this.dropdown.find('.dropdown-input-field'); + if (this.options.filterable) { + $input.blur(); + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + } + if (this.options.hidden) { + this.options.hidden.call(this, e); + } + return this.dropdown.trigger('hidden.gl.dropdown'); } - return this.dropdown.trigger('hidden.gl.dropdown'); -}; -// Render the full menu -GitLabDropdown.prototype.renderMenu = function(html) { - if (this.options.renderMenu) { - return this.options.renderMenu(html); - } else { + // Render the full menu + renderMenu(html) { + if (this.options.renderMenu) { + return this.options.renderMenu(html); + } return $('<ul>').append(html); } -}; -// Append the menu into the dropdown -GitLabDropdown.prototype.appendMenu = function(html) { - return this.clearMenu().append(html); -}; + // Append the menu into the dropdown + appendMenu(html) { + return this.clearMenu().append(html); + } -GitLabDropdown.prototype.clearMenu = function() { - let selector; - selector = '.dropdown-content'; - if (this.dropdown.find('.dropdown-toggle-page').length) { - if (this.options.containerSelector) { - selector = this.options.containerSelector; - } else { - selector = '.dropdown-page-one .dropdown-content'; + clearMenu() { + let selector = '.dropdown-content'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + if (this.options.containerSelector) { + selector = this.options.containerSelector; + } else { + selector = '.dropdown-page-one .dropdown-content'; + } } + + return $(selector, this.dropdown).empty(); } - return $(selector, this.dropdown).empty(); -}; + renderItem(data, group, index) { + let parent; -GitLabDropdown.prototype.renderItem = function(data, group, index) { - let parent; - - if (this.dropdown && this.dropdown[0]) { - parent = this.dropdown[0].parentNode; - } - - return renderItem({ - instance: this, - options: Object.assign({}, this.options, { - icon: this.icon, - highlight: this.highlight, - highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), - highlightTemplate: this.highlightTemplate.bind(this), - parent, - }), - data, - group, - index, - }); -}; + if (this.dropdown && this.dropdown[0]) { + parent = this.dropdown[0].parentNode; + } -GitLabDropdown.prototype.highlightTemplate = function(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; -}; + return renderItem({ + instance: this, + options: Object.assign({}, this.options, { + icon: this.icon, + highlight: this.highlight, + highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), + highlightTemplate: this.highlightTemplate.bind(this), + parent, + }), + data, + group, + index, + }); + } -GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - const occurrences = fuzzaldrinPlus.match(text, term); - const { indexOf } = []; + // eslint-disable-next-line class-methods-use-this + highlightTemplate(text, template) { + return `"<b>${_.escape(text)}</b>" ${template}`; + } - return text - .split('') - .map((character, i) => { - if (indexOf.call(occurrences, i) !== -1) { - return `<b>${character}</b>`; - } else { + // eslint-disable-next-line class-methods-use-this + highlightTextMatches(text, term) { + const occurrences = fuzzaldrinPlus.match(text, term); + const { indexOf } = []; + + return text + .split('') + .map((character, i) => { + if (indexOf.call(occurrences, i) !== -1) { + return `<b>${character}</b>`; + } return character; + }) + .join(''); + } + + // eslint-disable-next-line class-methods-use-this + noResults() { + return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; + } + + rowClicked(el) { + let field, groupName, selectedIndex, selectedObject, isMarking; + const { fieldName } = this.options; + const isInput = $(this.el).is('input'); + if (this.renderedData) { + groupName = el.data('group'); + if (groupName) { + selectedIndex = el.data('index'); + selectedObject = this.renderedData[groupName][selectedIndex]; + } else { + selectedIndex = el.closest('li').index(); + this.selectedIndex = selectedIndex; + selectedObject = this.renderedData[selectedIndex]; } - }) - .join(''); -}; + } -GitLabDropdown.prototype.noResults = function() { - return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; -}; + if (this.options.vue) { + if (el.hasClass(ACTIVE_CLASS)) { + el.removeClass(ACTIVE_CLASS); + } else { + el.addClass(ACTIVE_CLASS); + } -GitLabDropdown.prototype.rowClicked = function(el) { - let field, groupName, selectedIndex, selectedObject, isMarking; - const { fieldName } = this.options; - const isInput = $(this.el).is('input'); - if (this.renderedData) { - groupName = el.data('group'); - if (groupName) { - selectedIndex = el.data('index'); - selectedObject = this.renderedData[groupName][selectedIndex]; - } else { - selectedIndex = el.closest('li').index(); - this.selectedIndex = selectedIndex; - selectedObject = this.renderedData[selectedIndex]; + return [selectedObject]; } - } - if (this.options.vue) { - if (el.hasClass(ACTIVE_CLASS)) { - el.removeClass(ACTIVE_CLASS); - } else { - el.addClass(ACTIVE_CLASS); + field = []; + const value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; + if (isInput) { + field = $(this.el); + } else if (value != null) { + field = this.dropdown + .parent() + .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`); } - return [selectedObject]; - } + if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { + return [selectedObject]; + } - field = []; - const value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; - if (isInput) { - field = $(this.el); - } else if (value != null) { - field = this.dropdown - .parent() - .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`); - } - - if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { - return [selectedObject]; - } - - if (el.hasClass(ACTIVE_CLASS) && value !== 0) { - isMarking = false; - el.removeClass(ACTIVE_CLASS); - if (field && field.length) { - this.clearField(field, isInput); - } - } else if (el.hasClass(INDETERMINATE_CLASS)) { - isMarking = true; - el.addClass(ACTIVE_CLASS); - el.removeClass(INDETERMINATE_CLASS); - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } - } else { - isMarking = true; - if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); - if (!isInput) { - this.dropdown - .parent() - .find(`input[name='${fieldName}']`) - .remove(); + if (el.hasClass(ACTIVE_CLASS) && value !== 0) { + isMarking = false; + el.removeClass(ACTIVE_CLASS); + if (field && field.length) { + this.clearField(field, isInput); + } + } else if (el.hasClass(INDETERMINATE_CLASS)) { + isMarking = true; + el.addClass(ACTIVE_CLASS); + el.removeClass(INDETERMINATE_CLASS); + if (field && field.length && value == null) { + this.clearField(field, isInput); } - } - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - // Toggle active class for the tick mark - el.addClass(ACTIVE_CLASS); - if (value != null) { if ((!field || !field.length) && fieldName) { this.addInput(fieldName, value, selectedObject); - } else if (field && field.length) { - field.val(value).trigger('change'); + } + } else { + isMarking = true; + if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { + this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); + if (!isInput) { + this.dropdown + .parent() + .find(`input[name='${fieldName}']`) + .remove(); + } + } + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + // Toggle active class for the tick mark + el.addClass(ACTIVE_CLASS); + if (value != null) { + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } else if (field && field.length) { + field.val(value).trigger('change'); + } } } + + return [selectedObject, isMarking]; } - return [selectedObject, isMarking]; -}; + focusTextInput() { + if (this.options.filterable) { + const initialScrollTop = $(window).scrollTop(); -GitLabDropdown.prototype.focusTextInput = function() { - if (this.options.filterable) { - const initialScrollTop = $(window).scrollTop(); + if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { + this.filterInput.focus(); + } - if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { - this.filterInput.focus(); + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); + } } + } - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); + addInput(fieldName, value, selectedObject, single) { + // Create hidden input for form + if (single) { + $(`input[name="${fieldName}"]`).remove(); } - } -}; -GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) { - // Create hidden input for form - if (single) { - $(`input[name="${fieldName}"]`).remove(); - } + const $input = $('<input>') + .attr('type', 'hidden') + .attr('name', fieldName) + .val(value); + if (this.options.inputId != null) { + $input.attr('id', this.options.inputId); + } - const $input = $('<input>') - .attr('type', 'hidden') - .attr('name', fieldName) - .val(value); - if (this.options.inputId != null) { - $input.attr('id', this.options.inputId); - } + if (this.options.multiSelect) { + Object.keys(selectedObject).forEach(attribute => { + $input.attr(`data-${attribute}`, selectedObject[attribute]); + }); + } - if (this.options.multiSelect) { - Object.keys(selectedObject).forEach(attribute => { - $input.attr(`data-${attribute}`, selectedObject[attribute]); - }); - } + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } - if (this.options.inputMeta) { - $input.attr('data-meta', selectedObject[this.options.inputMeta]); + this.dropdown.before($input).trigger('change'); } - this.dropdown.before($input).trigger('change'); -}; - -GitLabDropdown.prototype.selectRowAtIndex = function(index) { - let selector; - // If we pass an option index - if (typeof index !== 'undefined') { - selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; - } else { - selector = '.dropdown-content .is-focused'; - } - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - // simulate a click on the first link - const $el = $(selector, this.dropdown); - if ($el.length) { - const href = $el.attr('href'); - if (href && href !== '#') { - visitUrl(href); + selectRowAtIndex(index) { + // If we pass an option index + let selector; + if (typeof index !== 'undefined') { + selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; } else { - $el.trigger('click'); + selector = '.dropdown-content .is-focused'; + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + // simulate a click on the first link + const $el = $(selector, this.dropdown); + if ($el.length) { + const href = $el.attr('href'); + if (href && href !== '#') { + visitUrl(href); + } else { + $el.trigger('click'); + } } } -}; -GitLabDropdown.prototype.addArrowKeyEvent = function() { - let selector; - const ARROW_KEY_CODES = [38, 40]; - selector = SELECTABLE_CLASSES; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - return $('body').on('keydown', e => { - let $listItems, PREV_INDEX; - const currentKeyCode = e.which; - if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { - e.preventDefault(); - e.stopImmediatePropagation(); - PREV_INDEX = currentIndex; - $listItems = $(selector, this.dropdown); - // if @options.filterable - // $input.blur() - if (currentKeyCode === 40) { - // Move down - if (currentIndex < $listItems.length - 1) { - currentIndex += 1; + addArrowKeyEvent() { + const ARROW_KEY_CODES = [38, 40]; + let selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + return $('body').on('keydown', e => { + let $listItems, PREV_INDEX; + const currentKeyCode = e.which; + if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { + e.preventDefault(); + e.stopImmediatePropagation(); + PREV_INDEX = currentIndex; + $listItems = $(selector, this.dropdown); + // if @options.filterable + // $input.blur() + if (currentKeyCode === 40) { + // Move down + if (currentIndex < $listItems.length - 1) { + currentIndex += 1; + } + } else if (currentKeyCode === 38) { + // Move up + if (currentIndex > 0) { + currentIndex -= 1; + } } - } else if (currentKeyCode === 38) { - // Move up - if (currentIndex > 0) { - currentIndex -= 1; + if (currentIndex !== PREV_INDEX) { + this.highlightRowAtIndex($listItems, currentIndex); } + return false; } - if (currentIndex !== PREV_INDEX) { - this.highlightRowAtIndex($listItems, currentIndex); + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + this.selectRowAtIndex(); } - return false; - } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - this.selectRowAtIndex(); - } - }); -}; - -GitLabDropdown.prototype.removeArrowKeyEvent = function() { - return $('body').off('keydown'); -}; - -GitLabDropdown.prototype.resetRows = function resetRows() { - currentIndex = -1; - $('.is-focused', this.dropdown).removeClass('is-focused'); -}; - -GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { - if (!$listItems) { - $listItems = $(SELECTABLE_CLASSES, this.dropdown); - } - - // Remove the class for the previously focused row - $('.is-focused', this.dropdown).removeClass('is-focused'); - // Update the class for the row at the specific index - const $listItem = $listItems.eq(index); - $listItem.find('a:first-child').addClass('is-focused'); - // Dropdown content scroll area - const $dropdownContent = $listItem.closest('.dropdown-content'); - const dropdownScrollTop = $dropdownContent.scrollTop(); - const dropdownContentHeight = $dropdownContent.outerHeight(); - const dropdownContentTop = $dropdownContent.prop('offsetTop'); - const dropdownContentBottom = dropdownContentTop + dropdownContentHeight; - // Get the offset bottom of the list item - const listItemHeight = $listItem.outerHeight(); - const listItemTop = $listItem.prop('offsetTop'); - const listItemBottom = listItemTop + listItemHeight; - if (!index) { - // Scroll the dropdown content to the top - $dropdownContent.scrollTop(0); - } else if (index === $listItems.length - 1) { - // Scroll the dropdown content to the bottom - $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); - } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { - // Scroll the dropdown content down - $dropdownContent.scrollTop( - listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING, - ); - } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { - // Scroll the dropdown content up - return $dropdownContent.scrollTop( - listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING, - ); + }); } -}; -GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { - if (selected == null) { - selected = null; + // eslint-disable-next-line class-methods-use-this + removeArrowKeyEvent() { + return $('body').off('keydown'); } - if (el == null) { - el = null; - } - if (instance == null) { - instance = null; + + resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); } - let toggleText = this.options.toggleLabel(selected, el, instance); - if (this.options.updateLabel) { - // Option to override the dropdown label text - toggleText = this.options.updateLabel; + highlightRowAtIndex($listItems, index) { + if (!$listItems) { + // eslint-disable-next-line no-param-reassign + $listItems = $(SELECTABLE_CLASSES, this.dropdown); + } + + // Remove the class for the previously focused row + $('.is-focused', this.dropdown).removeClass('is-focused'); + // Update the class for the row at the specific index + const $listItem = $listItems.eq(index); + $listItem.find('a:first-child').addClass('is-focused'); + // Dropdown content scroll area + const $dropdownContent = $listItem.closest('.dropdown-content'); + const dropdownScrollTop = $dropdownContent.scrollTop(); + const dropdownContentHeight = $dropdownContent.outerHeight(); + const dropdownContentTop = $dropdownContent.prop('offsetTop'); + const dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + // Get the offset bottom of the list item + const listItemHeight = $listItem.outerHeight(); + const listItemTop = $listItem.prop('offsetTop'); + const listItemBottom = listItemTop + listItemHeight; + if (!index) { + // Scroll the dropdown content to the top + $dropdownContent.scrollTop(0); + } else if (index === $listItems.length - 1) { + // Scroll the dropdown content to the bottom + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { + // Scroll the dropdown content down + $dropdownContent.scrollTop( + listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING, + ); + } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { + // Scroll the dropdown content up + return $dropdownContent.scrollTop( + listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING, + ); + } } - return $(this.el) - .find('.dropdown-toggle-text') - .text(toggleText); -}; + updateLabel(selected = null, el = null, instance = null) { + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } -GitLabDropdown.prototype.clearField = function(field, isInput) { - return isInput ? field.val('') : field.remove(); -}; + return $(this.el) + .find('.dropdown-toggle-text') + .text(toggleText); + } + + // eslint-disable-next-line class-methods-use-this + clearField(field, isInput) { + return isInput ? field.val('') : field.remove(); + } +} +// eslint-disable-next-line func-names $.fn.glDropdown = function(opts) { + // eslint-disable-next-line func-names return this.each(function() { if (!$.data(this, 'glDropdown')) { return $.data(this, 'glDropdown', new GitLabDropdown(this, opts)); diff --git a/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql new file mode 100644 index 00000000000..9a2ff1c1648 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql @@ -0,0 +1,6 @@ +fragment Author on User { + avatarUrl + name + username + webUrl +} diff --git a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql new file mode 100644 index 00000000000..b202ed12f80 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql @@ -0,0 +1,7 @@ +fragment BlobViewer on SnippetBlobViewer { + collapsed + renderError + tooLarge + type + fileType +} diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js new file mode 100644 index 00000000000..a262fbd9ac3 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -0,0 +1,12 @@ +/** + * Ids generated by GraphQL endpoints are usually in the format + * gid://gitlab/Environments/123. This method extracts Id number + * from the Id path + * + * @param {String} gid GraphQL global ID + * @returns {Number} + */ +export const getIdFromGraphQLId = (gid = '') => + parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; + +export default {}; diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index e885b2b5f41..cf8c9bf74ec 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -44,7 +44,7 @@ export default { :action="action" /> <li v-if="hasMoreChildren" class="group-row"> - <a :href="parentGroup.relativePath" class="group-row-contents has-more-items"> + <a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2"> <i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }} </a> </li> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index ede74d18ed4..b192fb78631 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { visitUrl } from '../../lib/utils/url_utility'; import tooltip from '../../vue_shared/directives/tooltip'; import identicon from '../../vue_shared/components/identicon.vue'; @@ -17,6 +17,7 @@ export default { tooltip, }, components: { + GlBadge, GlLoadingIcon, identicon, itemCaret, @@ -62,6 +63,9 @@ export default { isGroup() { return this.group.type === 'group'; }, + isGroupPendingRemoval() { + return this.group.type === 'group' && this.group.pendingRemoval; + }, visibilityIcon() { return VISIBILITY_TYPE_ICON[this.group.visibility]; }, @@ -91,7 +95,7 @@ export default { <li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup"> <div :class="{ 'project-row-contents': !isGroup }" - class="group-row-contents d-flex align-items-center" + class="group-row-contents d-flex align-items-center py-2" > <div class="folder-toggle-wrap append-right-4 d-flex align-items-center"> <item-caret :is-group-open="group.isOpen" /> @@ -104,7 +108,7 @@ export default { /> <div :class="{ 'd-sm-flex': !group.isChildrenLoading }" - class="avatar-container rect-avatar s40 d-none flex-grow-0 flex-shrink-0 " + class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " > <a :href="group.relativePath" class="no-expand"> <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> @@ -139,6 +143,9 @@ export default { <span v-html="group.description"> </span> </div> </div> + <div v-if="isGroupPendingRemoval"> + <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> + </div> <div class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between" > diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 214ac5e3db5..6a1197fa163 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -93,7 +93,7 @@ export default class GroupsStore { memberCount: rawGroupItem.number_users_with_delimiter, starCount: rawGroupItem.star_count, updatedAt: rawGroupItem.updated_at, - pendingRemoval: rawGroupItem.marked_for_deletion_at, + pendingRemoval: rawGroupItem.marked_for_deletion, }; } diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js index 35ac7b2629c..7891b44dd27 100644 --- a/app/assets/javascripts/helpers/avatar_helper.js +++ b/app/assets/javascripts/helpers/avatar_helper.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape } from 'lodash'; import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; export const DEFAULT_SIZE_CLASS = 's40'; @@ -19,7 +19,7 @@ export function renderIdenticon(entity, options = {}) { const bgClass = getIdenticonBackgroundClass(entity.id); const title = getIdenticonTitle(entity.name); - return `<div class="avatar identicon ${_.escape(sizeClass)} ${_.escape(bgClass)}">${_.escape( + return `<div class="avatar identicon ${escape(sizeClass)} ${escape(bgClass)}">${escape( title, )}</div>`; } @@ -31,5 +31,5 @@ export function renderAvatar(entity, options = {}) { const { sizeClass = DEFAULT_SIZE_CLASS } = options; - return `<img src="${_.escape(entity.avatar_url)}" class="avatar ${_.escape(sizeClass)}" />`; + return `<img src="${escape(entity.avatar_url)}" class="avatar ${escape(sizeClass)}" />`; } diff --git a/app/assets/javascripts/helpers/diffs_helper.js b/app/assets/javascripts/helpers/diffs_helper.js index 9695d01ad3d..d2b8cb11fe0 100644 --- a/app/assets/javascripts/helpers/diffs_helper.js +++ b/app/assets/javascripts/helpers/diffs_helper.js @@ -1,9 +1,9 @@ export function hasInlineLines(diffFile) { - return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */ + return diffFile?.highlighted_diff_lines?.length > 0; } export function hasParallelLines(diffFile) { - return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */ + return diffFile?.parallel_diff_lines?.length > 0; } export function isSingleViewStyle(diffFile) { @@ -11,9 +11,5 @@ export function isSingleViewStyle(diffFile) { } export function hasDiff(diffFile) { - return ( - hasInlineLines(diffFile) || - hasParallelLines(diffFile) || - !diffFile?.blob?.readable_text /* eslint-disable-line camelcase */ - ); + return hasInlineLines(diffFile) || hasParallelLines(diffFile) || !diffFile?.blob?.readable_text; } diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 7b4e03be8eb..186d4b6d7d2 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -3,7 +3,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; -import { activityBarViews } from '../constants'; +import { leftSidebarViews } from '../constants'; export default { components: { @@ -26,7 +26,7 @@ export default { $(e.currentTarget).tooltip('hide'); }, }, - activityBarViews, + leftSidebarViews, }; </script> @@ -37,7 +37,7 @@ export default { <button v-tooltip :class="{ - active: currentActivityView === $options.activityBarViews.edit, + active: currentActivityView === $options.leftSidebarViews.edit.name, }" :title="s__('IDE|Edit')" :aria-label="s__('IDE|Edit')" @@ -45,7 +45,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-edit-mode" - @click.prevent="changedActivityView($event, $options.activityBarViews.edit)" + @click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)" > <icon name="code" /> </button> @@ -54,7 +54,7 @@ export default { <button v-tooltip :class="{ - active: currentActivityView === $options.activityBarViews.review, + active: currentActivityView === $options.leftSidebarViews.review.name, }" :title="s__('IDE|Review')" :aria-label="s__('IDE|Review')" @@ -62,7 +62,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-review-mode" - @click.prevent="changedActivityView($event, $options.activityBarViews.review)" + @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)" > <icon name="file-modified" /> </button> @@ -71,7 +71,7 @@ export default { <button v-tooltip :class="{ - active: currentActivityView === $options.activityBarViews.commit, + active: currentActivityView === $options.leftSidebarViews.commit.name, }" :title="s__('IDE|Commit')" :aria-label="s__('IDE|Commit')" @@ -79,7 +79,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-commit-mode qa-commit-mode-tab" - @click.prevent="changedActivityView($event, $options.activityBarViews.commit)" + @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)" > <icon name="commit" /> </button> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 549324831e9..2581c3e9928 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; -import { sprintf, __ } from '~/locale'; +import { sprintf, s__ } from '~/locale'; import consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; import NewMergeRequestOption from './new_merge_request_option.vue'; @@ -21,7 +21,7 @@ export default { ...mapGetters(['currentBranch']), commitToCurrentBranchText() { return sprintf( - __('Commit to %{branchName} branch'), + s__('IDE|Commit to %{branchName} branch'), { branchName: `<strong class="monospace">${_.escape(this.currentBranchId)}</strong>` }, false, ); @@ -56,8 +56,8 @@ export default { }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, - currentBranchPermissionsTooltip: __( - "This option is disabled as you don't have write permissions for the current branch", + currentBranchPermissionsTooltip: s__( + "IDE|This option is disabled because you don't have write permissions for the current branch.", ), }; </script> @@ -70,7 +70,7 @@ export default { :title="$options.currentBranchPermissionsTooltip" > <span - class="ide-radio-label" + class="ide-option-label" data-qa-selector="commit_to_current_branch_radio" v-html="commitToCurrentBranchText" ></span> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 9d5473a1201..5ec3fc4041b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -5,8 +5,7 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; -import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; export default { components: { @@ -15,7 +14,6 @@ export default { CommitMessageField, SuccessMessage, }, - mixins: [glFeatureFlagsMixin()], data() { return { isCompact: true, @@ -29,13 +27,9 @@ export default { ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return sprintf( - this.glFeatures.stageAllByDefault - ? __( - '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes', - ) - : __( - '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', - ), + __( + '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes', + ), { stagedFilesLength: this.stagedFiles.length, changedFilesLength: this.changedFiles.length, @@ -47,7 +41,7 @@ export default { }, currentViewIsCommitView() { - return this.currentActivityView === activityBarViews.commit; + return this.currentActivityView === leftSidebarViews.commit.name; }, }, watch: { @@ -63,7 +57,7 @@ export default { lastCommitMsg() { this.isCompact = - this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === ''; + this.currentActivityView !== leftSidebarViews.commit.name && this.lastCommitMsg === ''; }, }, methods: { @@ -73,7 +67,7 @@ export default { if (this.currentViewIsCommitView) { this.isCompact = !this.isCompact; } else { - this.updateActivityBarView(activityBarViews.commit) + this.updateActivityBarView(leftSidebarViews.commit.name) .then(() => { this.isCompact = false; }) @@ -102,7 +96,6 @@ export default { this.componentHeight = null; }, }, - activityBarViews, }; </script> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index daa44a42765..0812599c25c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,16 +1,27 @@ <script> import { createNamespacedHelpers } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; -const { - mapState: mapCommitState, - mapActions: mapCommitActions, - mapGetters: mapCommitGetters, -} = createNamespacedHelpers('commit'); +const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } = createNamespacedHelpers( + 'commit', +); export default { + directives: { + GlTooltip: GlTooltipDirective, + }, computed: { - ...mapCommitState(['shouldCreateMR']), - ...mapCommitGetters(['shouldHideNewMrOption']), + ...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']), + tooltipText() { + if (this.shouldDisableNewMrOption) { + return s__( + 'IDE|This option is disabled because you are not allowed to create merge requests in this project.', + ); + } + + return ''; + }, }, methods: { ...mapCommitActions(['toggleShouldCreateMR']), @@ -21,14 +32,19 @@ export default { <template> <fieldset v-if="!shouldHideNewMrOption"> <hr class="my-2" /> - <label class="mb-0 js-ide-commit-new-mr"> + <label + v-gl-tooltip="tooltipText" + class="mb-0 js-ide-commit-new-mr" + :class="{ 'is-disabled': shouldDisableNewMrOption }" + > <input + :disabled="shouldDisableNewMrOption" :checked="shouldCreateMR" type="checkbox" data-qa-selector="start_new_mr_checkbox" @change="toggleShouldCreateMR" /> - <span class="prepend-left-10"> + <span class="prepend-left-10 ide-option-label"> {{ __('Start a new merge request') }} </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 9161eb3d9b1..a9591805261 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,10 +1,10 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlTooltipDirective } from '@gitlab/ui'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { value: { @@ -53,8 +53,7 @@ export default { <template> <fieldset> <label - v-tooltip - :title="tooltipTitle" + v-gl-tooltip="tooltipTitle" :class="{ 'is-disabled': disabled, }" @@ -68,7 +67,7 @@ export default { @change="updateCommitAction($event.target.value)" /> <span class="prepend-left-10"> - <span v-if="label" class="ide-radio-label"> {{ label }} </span> <slot v-else></slot> + <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot> </span> </label> <div v-if="commitAction === value && showInput" class="ide-commit-new-branch"> diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index 500f6737839..d36adbd798e 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,9 +1,10 @@ <script> import { mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; export default { components: { + GlAlert, GlLoadingIcon, }, props: { @@ -17,9 +18,14 @@ export default { isLoading: false, }; }, + computed: { + canDismiss() { + return !this.message.action; + }, + }, methods: { ...mapActions(['setErrorMessage']), - clickAction() { + doAction() { if (this.isLoading) return; this.isLoading = true; @@ -33,28 +39,23 @@ export default { this.isLoading = false; }); }, - clickFlash() { - if (!this.message.action) { - this.setErrorMessage(null); - } + dismiss() { + this.setErrorMessage(null); }, }, }; </script> <template> - <div class="flash-container flash-container-page" @click="clickFlash"> - <div class="flash-alert" data-qa-selector="flash_alert"> - <span v-html="message.text"> </span> - <button - v-if="message.action" - type="button" - class="flash-action text-white p-0 border-top-0 border-right-0 border-left-0 bg-transparent" - @click.stop.prevent="clickAction" - > - {{ message.actionText }} - <gl-loading-icon v-show="isLoading" inline /> - </button> - </div> - </div> + <gl-alert + data-qa-selector="flash_alert" + variant="danger" + :dismissible="canDismiss" + :primary-button-text="message.actionText" + @dismiss="dismiss" + @primaryAction="doAction" + > + <span v-html="message.text"></span> + <gl-loading-icon v-show="isLoading" inline class="vertical-align-middle ml-1" /> + </gl-alert> </template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 33098eb1af0..3ef7d863bd5 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -6,7 +6,6 @@ import Icon from '~/vue_shared/components/icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import NewDropdown from './new_dropdown/index.vue'; import MrFileIcon from './mr_file_icon.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'FileRowExtra', @@ -19,7 +18,6 @@ export default { ChangedFileIcon, MrFileIcon, }, - mixins: [glFeatureFlagsMixin()], props: { file: { type: Object, @@ -57,15 +55,10 @@ export default { return n__('%d staged change', '%d staged changes', this.folderStagedCount); } - return sprintf( - this.glFeatures.stageAllByDefault - ? __('%{staged} staged and %{unstaged} unstaged changes') - : __('%{unstaged} unstaged and %{staged} staged changes'), - { - unstaged: this.folderUnstagedCount, - staged: this.folderStagedCount, - }, - ); + return sprintf(__('%{staged} staged and %{unstaged} unstaged changes'), { + unstaged: this.folderUnstagedCount, + staged: this.folderStagedCount, + }); }, showTreeChangesCount() { return this.isTree && this.changesCount > 0 && !this.file.opened; diff --git a/app/assets/javascripts/ide/components/ide_file_row.vue b/app/assets/javascripts/ide/components/ide_file_row.vue new file mode 100644 index 00000000000..b777d89f0bb --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_file_row.vue @@ -0,0 +1,38 @@ +<script> +/** + * This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue` + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720 + */ +import FileRow from '~/vue_shared/components/file_row.vue'; +import FileRowExtra from './file_row_extra.vue'; + +export default { + name: 'IdeFileRow', + components: { + FileRow, + FileRowExtra, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + data() { + return { + dropdownOpen: false, + }; + }, + methods: { + toggleDropdown(val) { + this.dropdownOpen = val; + }, + }, +}; +</script> + +<template> + <file-row :file="file" v-bind="$attrs" @mouseleave="toggleDropdown(false)" v-on="$listeners"> + <file-row-extra :file="file" :dropdown-open="dropdownOpen" @toggle="toggleDropdown($event)" /> + </file-row> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 6178d2b1fc7..40cd2178e09 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -4,19 +4,19 @@ import { GlSkeletonLoading } from '@gitlab/ui'; import IdeTree from './ide_tree.vue'; import ResizablePanel from './resizable_panel.vue'; import ActivityBar from './activity_bar.vue'; -import CommitSection from './repo_commit_section.vue'; +import RepoCommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; import SuccessMessage from './commit_sidebar/success_message.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { activityBarViews } from '../constants'; +import { leftSidebarViews } from '../constants'; export default { components: { GlSkeletonLoading, ResizablePanel, ActivityBar, - CommitSection, + RepoCommitSection, IdeTree, CommitForm, IdeReview, @@ -28,7 +28,7 @@ export default { ...mapGetters(['currentProject', 'someUncommittedChanges']), showSuccessMessage() { return ( - this.currentActivityView === activityBarViews.edit && + this.currentActivityView === leftSidebarViews.edit.name && (this.lastCommitMsg && !this.someUncommittedChanges) ); }, diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 6eaf08e8033..7ce33fd2278 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -2,6 +2,7 @@ /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue'; +import IdeStatusMr from './ide_status_mr.vue'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; @@ -15,6 +16,7 @@ export default { userAvatarImage, CiIcon, IdeStatusList, + IdeStatusMr, }, directives: { tooltip, @@ -27,7 +29,7 @@ export default { }, computed: { ...mapState(['currentBranchId', 'currentProjectId']), - ...mapGetters(['currentProject', 'lastCommit']), + ...mapGetters(['currentProject', 'lastCommit', 'currentMergeRequest']), ...mapState('pipelines', ['latestPipeline']), }, watch: { @@ -79,7 +81,7 @@ export default { <span v-if="latestPipeline && latestPipeline.details" class="ide-status-pipeline"> <button type="button" - class="p-0 border-0 h-50" + class="p-0 border-0 bg-transparent" @click="openRightPane($options.rightSidebarViews.pipelines)" > <ci-icon @@ -121,6 +123,12 @@ export default { >{{ lastCommitFormattedAge }}</time > </div> + <ide-status-mr + v-if="currentMergeRequest" + class="mx-3" + :url="currentMergeRequest.web_url" + :text="currentMergeRequest.references.short" + /> <ide-status-list class="ml-auto" /> </footer> </template> diff --git a/app/assets/javascripts/ide/components/ide_status_mr.vue b/app/assets/javascripts/ide/components/ide_status_mr.vue new file mode 100644 index 00000000000..a3b26d23a17 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_mr.vue @@ -0,0 +1,28 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + text: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="d-flex-center flex-nowrap text-nowrap js-ide-status-mr"> + <gl-icon name="merge-request" /> + <span class="ml-1 d-none d-sm-block">{{ s__('WebIDE|Merge request') }}</span> + <gl-link class="ml-1" :href="url">{{ text }}</gl-link> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index bacdfc7c05e..36e8951bea3 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -1,15 +1,15 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; -import FileRow from '~/vue_shared/components/file_row.vue'; +import FileTree from '~/vue_shared/components/file_tree.vue'; +import IdeFileRow from './ide_file_row.vue'; import NavDropdown from './nav_dropdown.vue'; -import FileRowExtra from './file_row_extra.vue'; export default { components: { GlSkeletonLoading, NavDropdown, - FileRow, + FileTree, }, props: { viewerType: { @@ -35,7 +35,7 @@ export default { methods: { ...mapActions(['updateViewer', 'toggleTreeOpen']), }, - FileRowExtra, + IdeFileRow, }; </script> @@ -53,12 +53,12 @@ export default { </header> <div class="ide-tree-body h-100"> <template v-if="currentTree.tree.length"> - <file-row + <file-tree v-for="file in currentTree.tree" :key="file.key" :file="file" :level="0" - :extra-component="$options.FileRowExtra" + :file-row-component="$options.IdeFileRow" @toggleTreeOpen="toggleTreeOpen" /> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 7280fba9e7a..9c0c97bc5ae 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -26,7 +26,7 @@ export default { <ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" /> <span class="prepend-left-8"> {{ job.name }} - <a :href="job.path" target="_blank" class="ide-external-link"> + <a :href="job.path" target="_blank" class="ide-external-link position-relative"> {{ jobId }} <icon :size="12" name="external-link" /> </a> </span> diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 52ca61c06b0..ba8407382f4 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -71,7 +71,7 @@ export default { v-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" - class="prepend-left-8 ide-stage-title" + class="prepend-left-8 text-truncate" > {{ stage.name }} </strong> @@ -80,7 +80,7 @@ export default { </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> - <div v-show="!stage.isCollapsed" ref="jobList" class="card-body"> + <div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0"> <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/merge_requests/info.vue b/app/assets/javascripts/ide/components/merge_requests/info.vue deleted file mode 100644 index 73ec992466c..00000000000 --- a/app/assets/javascripts/ide/components/merge_requests/info.vue +++ /dev/null @@ -1,38 +0,0 @@ -<script> -import { mapGetters } from 'vuex'; -import Icon from '../../../vue_shared/components/icon.vue'; -import TitleComponent from '../../../issue_show/components/title.vue'; -import DescriptionComponent from '../../../issue_show/components/description.vue'; - -export default { - components: { - Icon, - TitleComponent, - DescriptionComponent, - }, - computed: { - ...mapGetters(['currentMergeRequest']), - }, -}; -</script> - -<template> - <div class="ide-merge-request-info h-100 d-flex flex-column"> - <div class="detail-page-header"> - <icon name="git-merge" class="align-self-center append-right-8" /> - <strong> !{{ currentMergeRequest.iid }} </strong> - </div> - <div class="issuable-details"> - <title-component - :issuable-ref="currentMergeRequest.iid" - :title-html="currentMergeRequest.title_html" - :title-text="currentMergeRequest.title" - /> - <description-component - :description-html="currentMergeRequest.description_html" - :description-text="currentMergeRequest.description" - :can-update="false" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index 2e290de0943..2307efd1d24 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; +import { mapGetters } from 'vuex'; import NavForm from './nav_form.vue'; import NavDropdownButton from './nav_dropdown_button.vue'; @@ -13,6 +14,9 @@ export default { isVisibleDropdown: false, }; }, + computed: { + ...mapGetters(['canReadMergeRequests']), + }, mounted() { this.addDropdownListeners(); }, @@ -42,7 +46,9 @@ export default { <template> <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown"> - <nav-dropdown-button /> - <div class="dropdown-menu dropdown-menu-left p-0"><nav-form v-if="isVisibleDropdown" /></div> + <nav-dropdown-button :show-merge-requests="canReadMergeRequests" /> + <div class="dropdown-menu dropdown-menu-left p-0"> + <nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" /> + </div> </div> </template> diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue index f1d44443125..4cd320d5d66 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue @@ -10,6 +10,13 @@ export default { Icon, DropdownButton, }, + props: { + showMergeRequests: { + type: Boolean, + required: false, + default: true, + }, + }, computed: { ...mapState(['currentBranchId', 'currentMergeRequestId']), mergeRequestLabel() { @@ -25,10 +32,10 @@ export default { <template> <dropdown-button> <span class="row"> - <span class="col-7 text-truncate"> + <span class="col-auto text-truncate" :class="{ 'col-7': showMergeRequests }"> <icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }} </span> - <span class="col-5 pl-0 text-truncate"> + <span v-if="showMergeRequests" class="col-5 pl-0 text-truncate"> <icon :size="16" :aria-label="__('Merge Request')" name="merge-request" /> {{ mergeRequestLabel }} </span> diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue index 23c068f329d..195504a6861 100644 --- a/app/assets/javascripts/ide/components/nav_form.vue +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -11,24 +11,32 @@ export default { BranchesSearchList, MergeRequestSearchList, }, + props: { + showMergeRequests: { + type: Boolean, + required: false, + default: true, + }, + }, }; </script> <template> <div class="ide-nav-form p-0"> - <tabs stop-propagation> + <tabs v-if="showMergeRequests" stop-propagation> <tab active> <template slot="title"> - {{ __('Merge Requests') }} + {{ __('Branches') }} </template> - <merge-request-search-list /> + <branches-search-list /> </tab> <tab> <template slot="title"> - {{ __('Branches') }} + {{ __('Merge Requests') }} </template> - <branches-search-list /> + <merge-request-search-list /> </tab> </tabs> + <branches-search-list v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 27d24fa5e1d..9961c0df52e 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -64,7 +64,7 @@ export default { class="rounded border-0 d-flex ide-entry-dropdown-toggle" @click.stop="openDropdown()" > - <icon name="ellipsis_v" /> <icon name="arrow-down" /> + <icon name="ellipsis_v" /> <icon name="chevron-down" /> </button> <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right"> <template v-if="type === 'tree'"> @@ -91,7 +91,7 @@ export default { </template> <li> <item-button - :label="__('Rename')" + :label="__('Rename/Move')" class="d-flex" icon="pencil" icon-classes="mr-2" diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index e52613086a4..0efb0012246 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -43,21 +43,28 @@ export default { }, createFile(target, file) { const { name } = file; - let { result } = target; - const encodedContent = result.split('base64,')[1]; + const encodedContent = target.result.split('base64,')[1]; const rawContent = encodedContent ? atob(encodedContent) : ''; const isText = this.isText(rawContent, file.type); - result = isText ? rawContent : encodedContent; + const emitCreateEvent = content => + this.$emit('create', { + name: `${this.path ? `${this.path}/` : ''}${name}`, + type: 'blob', + content, + base64: !isText, + binary: !isText, + rawPath: !isText ? target.result : '', + }); - this.$emit('create', { - name: `${this.path ? `${this.path}/` : ''}${name}`, - type: 'blob', - content: result, - base64: !isText, - binary: !isText, - rawPath: !isText ? target.result : '', - }); + if (isText) { + const reader = new FileReader(); + + reader.addEventListener('load', e => emitCreateEvent(e.target.result), { once: true }); + reader.readAsText(file); + } else { + emitCreateEvent(encodedContent); + } }, readFile(file) { const reader = new FileReader(); diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 40ed7d9c422..4a9de9e0c03 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -3,7 +3,6 @@ import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import CollapsibleSidebar from './collapsible_sidebar.vue'; import { rightSidebarViews } from '../../constants'; -import MergeRequestInfo from '../merge_requests/info.vue'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; import Clientside from '../preview/clientside.vue'; @@ -29,12 +28,6 @@ export default { rightExtensionTabs() { return [ { - show: Boolean(this.currentMergeRequestId), - title: __('Merge Request'), - views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }], - icon: 'text-description', - }, - { show: true, title: __('Pipelines'), views: [ diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 5ae73b2fc9c..b61d0a47795 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -62,7 +62,11 @@ export default { <ci-icon :status="latestPipeline.details.status" :size="24" /> <span class="prepend-left-8"> <strong> {{ __('Pipeline') }} </strong> - <a :href="latestPipeline.path" target="_blank" class="ide-external-link"> + <a + :href="latestPipeline.path" + target="_blank" + class="ide-external-link position-relative" + > #{{ latestPipeline.id }} <icon :size="12" name="external-link" /> </a> </span> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index b3a7597e7bb..62fb0b03975 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -5,7 +5,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; import consts from '../stores/modules/commit/constants'; -import { activityBarViews, stageKeys } from '../constants'; +import { leftSidebarViews, stageKeys } from '../constants'; export default { components: { @@ -37,7 +37,7 @@ export default { watch: { hasChanges() { if (!this.hasChanges) { - this.updateActivityBarView(activityBarViews.edit); + this.updateActivityBarView(leftSidebarViews.edit.name); } }, }, diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 7e2ab96d1de..bfb760f3579 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -5,7 +5,7 @@ import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { - activityBarViews, + leftSidebarViews, viewerTypes, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW, @@ -38,6 +38,7 @@ export default { 'panelResizing', 'currentActivityView', 'renderWhitespaceInCode', + 'editorTheme', ]), ...mapGetters([ 'currentMergeRequest', @@ -85,6 +86,7 @@ export default { editorOptions() { return { renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none', + theme: this.editorTheme, }; }, }, @@ -98,7 +100,7 @@ export default { if (oldVal.key !== this.file.key) { this.initEditor(); - if (this.currentActivityView !== activityBarViews.edit) { + if (this.currentActivityView !== leftSidebarViews.edit.name) { this.setFileViewMode({ file: this.file, viewMode: FILE_VIEW_MODE_EDITOR, @@ -107,7 +109,7 @@ export default { } }, currentActivityView() { - if (this.currentActivityView !== activityBarViews.edit) { + if (this.currentActivityView !== leftSidebarViews.edit.name) { this.setFileViewMode({ file: this.file, viewMode: FILE_VIEW_MODE_EDITOR, @@ -274,7 +276,7 @@ export default { <template> <div id="ide" class="blob-viewer-container blob-editor-container"> <div class="ide-mode-tabs clearfix"> - <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left"> + <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left border-bottom-0"> <li :class="editTabCSS"> <a href="javascript:void(0);" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 673ac1bfa9a..e7762f9e0f2 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -8,11 +8,8 @@ export const MAX_BODY_LENGTH = 72; export const FILE_VIEW_MODE_EDITOR = 'editor'; export const FILE_VIEW_MODE_PREVIEW = 'preview'; -export const activityBarViews = { - edit: 'ide-tree', - commit: 'commit-section', - review: 'ide-review', -}; +export const PERMISSION_CREATE_MR = 'createMergeRequestIn'; +export const PERMISSION_READ_MR = 'readMergeRequest'; export const viewerTypes = { mr: 'mrdiff', @@ -44,6 +41,12 @@ export const diffViewerErrors = Object.freeze({ stored_externally: 'server_side_but_stored_externally', }); +export const leftSidebarViews = { + edit: { name: 'ide-tree', keepAlive: false }, + review: { name: 'ide-review', keepAlive: false }, + commit: { name: 'repo-commit-section', keepAlive: false }, +}; + export const rightSidebarViews = { pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 8c84b98a108..0fab3ee0f3b 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,11 +1,11 @@ import Vue from 'vue'; -import VueRouter from 'vue-router'; +import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; import store from './stores'; import { __ } from '~/locale'; -Vue.use(VueRouter); +Vue.use(IdeRouter); /** * Routes below /-/ide/: @@ -33,7 +33,7 @@ const EmptyRouterComponent = { }, }; -const router = new VueRouter({ +const router = new IdeRouter({ mode: 'history', base: joinPaths(gon.relative_url_root || '', '/-/ide/'), routes: [ diff --git a/app/assets/javascripts/ide/ide_router_extension.js b/app/assets/javascripts/ide/ide_router_extension.js new file mode 100644 index 00000000000..a146aca7283 --- /dev/null +++ b/app/assets/javascripts/ide/ide_router_extension.js @@ -0,0 +1,21 @@ +import VueRouter from 'vue-router'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; + +// To allow special characters (like "#," for example) in the branch names, we +// should encode all the locations before those get processed by History API. +// Otherwise, paths get messed up so that the router receives incorrect +// branchid. The only way to do it consistently and in a more or less +// future-proof manner is, unfortunately, to monkey-patch VueRouter or, as +// suggested here, achieve the same more reliably by subclassing VueRouter and +// update the methods, used in WebIDE. +// +// More context: https://gitlab.com/gitlab-org/gitlab/issues/35473 + +export default class IDERouter extends VueRouter { + push(location, onComplete, onAbort) { + super.push(escapeFileUrl(location), onComplete, onAbort); + } + resolve(to, current, append) { + return super.resolve(escapeFileUrl(to), current, append); + } +} diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 4c4166e11f5..a3450522697 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -7,6 +7,7 @@ import store from './stores'; import router from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { DEFAULT_THEME } from './lib/themes'; Vue.use(Translate); @@ -51,6 +52,7 @@ export function initIde(el, options = {}) { this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), + editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME, }); }, methods: { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index d1056ea6b98..3d729463cb4 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -6,22 +6,16 @@ import DirtyDiffController from './diff/controller'; import Disposable from './common/disposable'; import ModelManager from './common/model_manager'; import editorOptions, { defaultEditorOptions } from './editor_options'; -import gitlabTheme from './themes/gl_theme'; +import { themes } from './themes'; import keymap from './keymap.json'; +import { clearDomElement } from '~/editor/utils'; -function setupMonacoTheme() { - monacoEditor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); - monacoEditor.setTheme('gitlab'); +function setupThemes() { + themes.forEach(theme => { + monacoEditor.defineTheme(theme.name, theme.data); + }); } -export const clearDomElement = el => { - if (!el || !el.firstChild) return; - - while (el.firstChild) { - el.removeChild(el.firstChild); - } -}; - export default class Editor { static create(options = {}) { if (!this.editorInstance) { @@ -42,7 +36,7 @@ export default class Editor { ...options, }; - setupMonacoTheme(); + setupThemes(); this.debouncedUpdate = _.debounce(() => { this.updateDimensions(); diff --git a/app/assets/javascripts/ide/lib/themes/dark.js b/app/assets/javascripts/ide/lib/themes/dark.js new file mode 100644 index 00000000000..96aaa0cbb50 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/dark.js @@ -0,0 +1,268 @@ +/* + +https://github.com/brijeshb42/monaco-themes/blob/master/themes/Tomorrow-Night.json + +The MIT License (MIT) + +Copyright (c) Brijesh Bittu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*/ + +export default { + base: 'vs-dark', + inherit: true, + rules: [ + { + foreground: '969896', + token: 'comment', + }, + { + foreground: 'ced1cf', + token: 'keyword.operator.class', + }, + { + foreground: 'ced1cf', + token: 'constant.other', + }, + { + foreground: 'ced1cf', + token: 'source.php.embedded.line', + }, + { + foreground: 'cc6666', + token: 'variable', + }, + { + foreground: 'cc6666', + token: 'support.other.variable', + }, + { + foreground: 'cc6666', + token: 'string.other.link', + }, + { + foreground: 'cc6666', + token: 'string.regexp', + }, + { + foreground: 'cc6666', + token: 'entity.name.tag', + }, + { + foreground: 'cc6666', + token: 'entity.other.attribute-name', + }, + { + foreground: 'cc6666', + token: 'meta.tag', + }, + { + foreground: 'cc6666', + token: 'declaration.tag', + }, + { + foreground: 'cc6666', + token: 'markup.deleted.git_gutter', + }, + { + foreground: 'de935f', + token: 'constant.numeric', + }, + { + foreground: 'de935f', + token: 'constant.language', + }, + { + foreground: 'de935f', + token: 'support.constant', + }, + { + foreground: 'de935f', + token: 'constant.character', + }, + { + foreground: 'de935f', + token: 'variable.parameter', + }, + { + foreground: 'de935f', + token: 'punctuation.section.embedded', + }, + { + foreground: 'de935f', + token: 'keyword.other.unit', + }, + { + foreground: 'f0c674', + token: 'entity.name.class', + }, + { + foreground: 'f0c674', + token: 'entity.name.type.class', + }, + { + foreground: 'f0c674', + token: 'support.type', + }, + { + foreground: 'f0c674', + token: 'support.class', + }, + { + foreground: 'b5bd68', + token: 'string', + }, + { + foreground: 'b5bd68', + token: 'constant.other.symbol', + }, + { + foreground: 'b5bd68', + token: 'entity.other.inherited-class', + }, + { + foreground: 'b5bd68', + token: 'markup.heading', + }, + { + foreground: 'b5bd68', + token: 'markup.inserted.git_gutter', + }, + { + foreground: '8abeb7', + token: 'keyword.operator', + }, + { + foreground: '8abeb7', + token: 'constant.other.color', + }, + { + foreground: '81a2be', + token: 'entity.name.function', + }, + { + foreground: '81a2be', + token: 'meta.function-call', + }, + { + foreground: '81a2be', + token: 'support.function', + }, + { + foreground: '81a2be', + token: 'keyword.other.special-method', + }, + { + foreground: '81a2be', + token: 'meta.block-level', + }, + { + foreground: '81a2be', + token: 'markup.changed.git_gutter', + }, + { + foreground: 'b294bb', + token: 'keyword', + }, + { + foreground: 'b294bb', + token: 'storage', + }, + { + foreground: 'b294bb', + token: 'storage.type', + }, + { + foreground: 'b294bb', + token: 'entity.name.tag.css', + }, + { + foreground: 'ced2cf', + background: 'df5f5f', + token: 'invalid', + }, + { + foreground: 'ced2cf', + background: '82a3bf', + token: 'meta.separator', + }, + { + foreground: 'ced2cf', + background: 'b798bf', + token: 'invalid.deprecated', + }, + { + foreground: 'ffffff', + token: 'markup.inserted.diff', + }, + { + foreground: 'ffffff', + token: 'markup.deleted.diff', + }, + { + foreground: 'ffffff', + token: 'meta.diff.header.to-file', + }, + { + foreground: 'ffffff', + token: 'meta.diff.header.from-file', + }, + { + foreground: '718c00', + token: 'markup.inserted.diff', + }, + { + foreground: '718c00', + token: 'meta.diff.header.to-file', + }, + { + foreground: 'c82829', + token: 'markup.deleted.diff', + }, + { + foreground: 'c82829', + token: 'meta.diff.header.from-file', + }, + { + foreground: 'ffffff', + background: '4271ae', + token: 'meta.diff.header.from-file', + }, + { + foreground: 'ffffff', + background: '4271ae', + token: 'meta.diff.header.to-file', + }, + { + foreground: '3e999f', + fontStyle: 'italic', + token: 'meta.diff.range', + }, + ], + colors: { + 'editor.foreground': '#C5C8C6', + 'editor.background': '#1D1F21', + 'editor.selectionBackground': '#373B41', + 'editor.lineHighlightBackground': '#282A2E', + 'editorCursor.foreground': '#AEAFAD', + 'editorWhitespace.foreground': '#4B4E55', + }, +}; diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js deleted file mode 100644 index 439ae50448a..00000000000 --- a/app/assets/javascripts/ide/lib/themes/gl_theme.js +++ /dev/null @@ -1,15 +0,0 @@ -export default { - themeName: 'gitlab', - monacoTheme: { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editorLineNumber.foreground': '#CCCCCC', - 'diffEditor.insertedTextBackground': '#ddfbe6', - 'diffEditor.removedTextBackground': '#f9d7dc', - 'editor.selectionBackground': '#aad6f8', - 'editorIndentGuide.activeBackground': '#cccccc', - }, - }, -}; diff --git a/app/assets/javascripts/ide/lib/themes/index.js b/app/assets/javascripts/ide/lib/themes/index.js new file mode 100644 index 00000000000..6ed9f6679a4 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/index.js @@ -0,0 +1,15 @@ +import white from './white'; +import dark from './dark'; + +export const themes = [ + { + name: 'white', + data: white, + }, + { + name: 'dark', + data: dark, + }, +]; + +export const DEFAULT_THEME = 'white'; diff --git a/app/assets/javascripts/ide/lib/themes/white.js b/app/assets/javascripts/ide/lib/themes/white.js new file mode 100644 index 00000000000..273bc783fc6 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/white.js @@ -0,0 +1,12 @@ +export default { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editorLineNumber.foreground': '#CCCCCC', + 'diffEditor.insertedTextBackground': '#A0F5B420', + 'diffEditor.removedTextBackground': '#f9d7dc20', + 'editor.selectionBackground': '#aad6f8', + 'editorIndentGuide.activeBackground': '#cccccc', + }, +}; diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql new file mode 100644 index 00000000000..48f63995f44 --- /dev/null +++ b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql @@ -0,0 +1,8 @@ +query getUserPermissions($projectPath: ID!) { + project(fullPath: $projectPath) { + userPermissions { + createMergeRequestIn, + readMergeRequest + } + } +} diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js new file mode 100644 index 00000000000..8a7f27328ba --- /dev/null +++ b/app/assets/javascripts/ide/services/gql.js @@ -0,0 +1,8 @@ +import createGqClient, { fetchPolicies } from '~/lib/graphql'; + +export default createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index b130e6e8b81..84a2b2bd58e 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,6 +1,18 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import Api from '~/api'; +import getUserPermissions from '../queries/getUserPermissions.query.graphql'; +import gqClient from './gql'; + +const fetchApiProjectData = projectPath => Api.project(projectPath).then(({ data }) => data); + +const fetchGqlProjectData = projectPath => + gqClient + .query({ + query: getUserPermissions, + variables: { projectPath }, + }) + .then(({ data }) => data.project); export default { getFileData(endpoint) { @@ -35,6 +47,7 @@ export default { joinPaths( gon.relative_url_root || '/', file.projectId, + '-', 'raw', sha, escapeFileUrl(filePath), @@ -46,7 +59,16 @@ export default { .then(({ data }) => data); }, getProjectData(namespace, project) { - return Api.project(`${namespace}/${project}`); + const projectPath = `${namespace}/${project}`; + + return Promise.all([fetchApiProjectData(projectPath), fetchGqlProjectData(projectPath)]).then( + ([apiProjectData, gqlProjectData]) => ({ + data: { + ...apiProjectData, + ...gqlProjectData, + }, + }), + ); }, getProjectMergeRequests(projectId, params = {}) { return Api.projectMergeRequests(projectId, params); @@ -67,7 +89,7 @@ export default { return Api.commitMultiple(projectId, payload); }, getFiles(projectUrl, ref) { - const url = `${projectUrl}/files/${ref}`; + const url = `${projectUrl}/-/files/${ref}`; return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 34e7cc304dd..ddc0925efb9 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -79,14 +79,10 @@ export const createTempEntry = ( if (type === 'blob') { commit(types.TOGGLE_FILE_OPEN, file.path); - - if (gon.features?.stageAllByDefault) - commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); - else commit(types.ADD_FILE_TO_CHANGED, file.path); + commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); dispatch('setFileActive', file.path); dispatch('triggerFilesChange'); - dispatch('burstUnusedSeal'); } if (parentPath && !state.entries[parentPath].opened) { @@ -175,12 +171,6 @@ export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, temp export const toggleFileFinder = ({ commit }, fileFindVisible) => commit(types.TOGGLE_FILE_FINDER, fileFindVisible); -export const burstUnusedSeal = ({ state, commit }) => { - if (state.unusedSeal) { - commit(types.BURST_UNUSED_SEAL); - } -}; - export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); export const setErrorMessage = ({ commit }, errorMessage) => @@ -209,8 +199,6 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { return; } - dispatch('burstUnusedSeal'); - if (entry.opened) dispatch('closeFile', entry); if (isTree) { @@ -259,11 +247,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, if (isReset) { commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry); } else if (!isInChanges) { - if (gon.features?.stageAllByDefault) - commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) }); - else commit(types.ADD_FILE_TO_CHANGED, newPath); - - dispatch('burstUnusedSeal'); + commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) }); } if (!newEntry.tempFile) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 70a966afa66..da7d4a44bde 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -71,6 +71,7 @@ export const getFileData = ( const url = joinPaths( gon.relative_url_root || '/', state.currentProjectId, + '-', file.type, getters.lastCommit && getters.lastCommit.id, escapeFileUrl(file.prevPath || file.path), @@ -89,7 +90,7 @@ export const getFileData = ( .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); dispatch('setErrorMessage', { - text: __('An error occurred whilst loading the file.'), + text: __('An error occurred while loading the file.'), action: payload => dispatch('getFileData', payload).then(() => dispatch('setErrorMessage', null)), actionText: __('Please try again'), @@ -136,7 +137,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = }) .catch(() => { dispatch('setErrorMessage', { - text: __('An error occurred whilst loading the file content.'), + text: __('An error occurred while loading the file content.'), action: payload => dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), actionText: __('Please try again'), @@ -147,7 +148,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = }); }; -export const changeFileContent = ({ commit, dispatch, state, getters }, { path, content }) => { +export const changeFileContent = ({ commit, state, getters }, { path, content }) => { const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, @@ -157,14 +158,10 @@ export const changeFileContent = ({ commit, dispatch, state, getters }, { path, const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); if (file.changed && indexOfChangedFile === -1) { - if (gon.features?.stageAllByDefault) - commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); - else commit(types.ADD_FILE_TO_CHANGED, path); + commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); } else if (!file.changed && !file.tempFile && indexOfChangedFile !== -1) { commit(types.REMOVE_FILE_FROM_CHANGED, path); } - - dispatch('burstUnusedSeal', {}, { root: true }); }; export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 806ec38430c..fcaf060ef09 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -2,10 +2,17 @@ import flash from '~/flash'; import { __ } from '~/locale'; import service from '../../services'; import * as types from '../mutation_types'; -import { activityBarViews } from '../../constants'; +import { leftSidebarViews, PERMISSION_READ_MR } from '../../constants'; -export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) => - service +export const getMergeRequestsForBranch = ( + { commit, state, getters }, + { projectId, branchId } = {}, +) => { + if (!getters.findProjectPermissions(projectId)[PERMISSION_READ_MR]) { + return Promise.resolve(); + } + + return service .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, source_project_id: state.projects[projectId].id, @@ -36,6 +43,7 @@ export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branch ); throw e; }); +}; export const getMergeRequestData = ( { commit, dispatch, state }, @@ -44,9 +52,7 @@ export const getMergeRequestData = ( new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { service - .getProjectMergeRequestData(targetProjectId || projectId, mergeRequestId, { - render_html: true, - }) + .getProjectMergeRequestData(targetProjectId || projectId, mergeRequestId) .then(({ data }) => { commit(types.SET_MERGE_REQUEST, { projectPath: projectId, @@ -58,7 +64,7 @@ export const getMergeRequestData = ( }) .catch(() => { dispatch('setErrorMessage', { - text: __('An error occurred whilst loading the merge request.'), + text: __('An error occurred while loading the merge request.'), action: payload => dispatch('getMergeRequestData', payload).then(() => dispatch('setErrorMessage', null), @@ -91,7 +97,7 @@ export const getMergeRequestChanges = ( }) .catch(() => { dispatch('setErrorMessage', { - text: __('An error occurred whilst loading the merge request changes.'), + text: __('An error occurred while loading the merge request changes.'), action: payload => dispatch('getMergeRequestChanges', payload).then(() => dispatch('setErrorMessage', null), @@ -125,7 +131,7 @@ export const getMergeRequestVersions = ( }) .catch(() => { dispatch('setErrorMessage', { - text: __('An error occurred whilst loading the merge request version data.'), + text: __('An error occurred while loading the merge request version data.'), action: payload => dispatch('getMergeRequestVersions', payload).then(() => dispatch('setErrorMessage', null), @@ -181,7 +187,7 @@ export const openMergeRequest = ( ) .then(mrChanges => { if (mrChanges.changes.length) { - dispatch('updateActivityBarView', activityBarViews.review); + dispatch('updateActivityBarView', leftSidebarViews.review.name); } mrChanges.changes.forEach((change, ind) => { diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index e206f9bee9e..62084892d13 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -133,9 +133,9 @@ export const loadBranch = ({ dispatch, getters }, { projectId, branchId }) => ref: branch.commit.id, }); }) - .catch(() => { + .catch(err => { dispatch('showBranchNotFoundError', branchId); - return Promise.reject(); + throw err; }); export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { @@ -152,7 +152,7 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, () => new Error( sprintf( - __('An error occurred whilst getting files for - %{branchId}'), + __('An error occurred while getting files for - %{branchId}'), { branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, }, diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index ba85194b910..828e4ed5eb9 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -77,7 +77,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => }) .catch(e => { dispatch('setErrorMessage', { - text: __('An error occurred whilst loading all the files.'), + text: __('An error occurred while loading all the files.'), action: actionPayload => dispatch('getFiles', actionPayload).then(() => dispatch('setErrorMessage', null)), actionText: __('Please try again'), diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 2fc574cd343..d7ad39019a5 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,5 +1,10 @@ import { getChangesCountForFiles, filePathMatches } from './utils'; -import { activityBarViews, packageJsonPath } from '../constants'; +import { + leftSidebarViews, + packageJsonPath, + PERMISSION_READ_MR, + PERMISSION_CREATE_MR, +} from '../constants'; export const activeFile = state => state.openFiles.find(file => file.active) || null; @@ -69,9 +74,11 @@ export const getOpenFile = state => path => state.openFiles.find(f => f.path === export const lastOpenedFile = state => [...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0]; -export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit; -export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit; -export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; +export const isEditModeActive = state => state.currentActivityView === leftSidebarViews.edit.name; +export const isCommitModeActive = state => + state.currentActivityView === leftSidebarViews.commit.name; +export const isReviewModeActive = state => + state.currentActivityView === leftSidebarViews.review.name; export const someUncommittedChanges = state => Boolean(state.changedFiles.length || state.stagedFiles.length); @@ -141,5 +148,14 @@ export const getDiffInfo = (state, getters) => path => { }; }; +export const findProjectPermissions = (state, getters) => projectId => + getters.findProject(projectId)?.userPermissions || {}; + +export const canReadMergeRequests = (state, getters) => + Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_READ_MR]); + +export const canCreateMergeRequests = (state, getters) => + Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_CREATE_MR]); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index e89ed49318b..9bf0542cd0b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -7,7 +7,7 @@ import router from '../../../ide_router'; import service from '../../../services'; import * as types from './mutation_types'; import consts from './constants'; -import { activityBarViews } from '../../../constants'; +import { leftSidebarViews } from '../../../constants'; import eventHub from '../../../eventhub'; export const updateCommitMessage = ({ commit }, message) => { @@ -44,7 +44,7 @@ export const setLastCommitMessage = ({ commit, rootGetters }, data) => { const commitMsg = sprintf( __('Your changes have been committed. Commit %{commitId} %{commitStats}'), { - commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${data.short_id}</a>`, + commitId: `<a href="${currentProject.web_url}/-/commit/${data.short_id}" class="commit-sha">${data.short_id}</a>`, commitStats, }, false, @@ -56,7 +56,7 @@ export const setLastCommitMessage = ({ commit, rootGetters }, data) => { export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => { const selectedProject = rootGetters.currentProject; const lastCommit = { - commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit_path: `${selectedProject.web_url}/-/commit/${data.id}`, commit: { id: data.id, message: data.message, @@ -158,7 +158,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); }, 5000); - if (state.shouldCreateMR) { + if (getters.shouldCreateMR) { const { currentProject } = rootGetters; const targetBranch = getters.isCreatingNewBranch ? rootState.currentBranchId @@ -189,7 +189,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo throw e; }); } else { - dispatch('updateActivityBarView', activityBarViews.edit, { root: true }); + dispatch('updateActivityBarView', leftSidebarViews.edit.name, { root: true }); dispatch('updateViewer', 'editor', { root: true }); if (rootGetters.activeFile) { @@ -218,7 +218,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'setErrorMessage', { - text: __('An error occurred whilst committing your changes.'), + text: __('An error occurred while committing your changes.'), action: () => dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true }), diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index de289e27199..e421d44b6de 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -54,5 +54,11 @@ export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters) (!rootGetters.hasMergeRequest && rootGetters.isOnDefaultBranch)) && rootGetters.canPushToBranch; +export const shouldDisableNewMrOption = (state, getters, rootState, rootGetters) => + !rootGetters.canCreateMergeRequests; + +export const shouldCreateMR = (state, getters) => + state.shouldCreateMR && !getters.shouldDisableNewMrOption; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index f10891a8e5b..453df8d7e0c 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,4 +1,4 @@ -import { activityBarViews } from '../../../constants'; +import { leftSidebarViews } from '../../../constants'; import { __ } from '~/locale'; export const templateTypes = () => [ @@ -22,6 +22,6 @@ export const templateTypes = () => [ export const showFileTemplatesBar = (_, getters, rootState) => name => getters.templateTypes.find(t => t.name === name) && - rootState.currentActivityView === activityBarViews.edit; + rootState.currentActivityView === leftSidebarViews.edit.name; export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js index 0eba9c39817..7576b2477d1 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js @@ -14,9 +14,10 @@ export default { iid: mergeRequest.iid, title: mergeRequest.title, projectId: mergeRequest.project_id, - projectPathWithNamespace: mergeRequest.web_url - .replace(`${gon.gitlab_url}/`, '') - .replace(`/merge_requests/${mergeRequest.iid}`, ''), + projectPathWithNamespace: mergeRequest.references.full.replace( + mergeRequest.references.short, + '', + ), })); }, [types.RESET_MERGE_REQUESTS](state) { diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 51cf4dede42..9862c556c2e 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -28,7 +28,7 @@ export const receiveLatestPipelineError = ({ commit, dispatch }, err) => { dispatch( 'setErrorMessage', { - text: __('An error occurred whilst fetching the latest pipeline.'), + text: __('An error occurred while fetching the latest pipeline.'), action: () => dispatch('forcePipelineRequest').then(() => dispatch('setErrorMessage', null, { root: true }), @@ -84,7 +84,7 @@ export const receiveJobsError = ({ commit, dispatch }, stage) => { dispatch( 'setErrorMessage', { - text: __('An error occurred whilst loading the pipelines jobs.'), + text: __('An error occurred while loading the pipelines jobs.'), action: payload => dispatch('fetchJobs', payload).then(() => dispatch('setErrorMessage', null, { root: true }), @@ -123,7 +123,7 @@ export const receiveJobTraceError = ({ commit, dispatch }) => { dispatch( 'setErrorMessage', { - text: __('An error occurred whilst fetching the job trace.'), + text: __('An error occurred while fetching the job trace.'), action: () => dispatch('fetchJobTrace').then(() => dispatch('setErrorMessage', null, { root: true })), actionText: __('Please try again'), diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 4dde53a9fdf..78831bdf022 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -67,7 +67,6 @@ export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW'; export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG'; export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; -export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index e84e2782e46..49485f4d575 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -180,11 +180,6 @@ export default { }); } }, - [types.BURST_UNUSED_SEAL](state) { - Object.assign(state, { - unusedSeal: false, - }); - }, [types.SET_LINKS](state, links) { Object.assign(state, { links }); }, @@ -226,6 +221,8 @@ export default { state.changedFiles = state.changedFiles.concat(entry); } } + + state.unusedSeal = false; }, [types.RENAME_ENTRY](state, { path, name, parentPath }) { const oldEntry = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 313fa1fe029..5c5920a3027 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -153,11 +153,13 @@ export default { [types.ADD_FILE_TO_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.concat(state.entries[path]), + unusedSeal: false, }); }, [types.REMOVE_FILE_FROM_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), + unusedSeal: false, }); }, [types.STAGE_CHANGE](state, { path, diffInfo }) { @@ -173,6 +175,7 @@ export default { deleted: diffInfo.deleted, }), }), + unusedSeal: false, }); if (stagedFile) { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 6488389977c..a714562c5b2 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,4 +1,5 @@ -import { activityBarViews, viewerTypes } from '../constants'; +import { leftSidebarViews, viewerTypes } from '../constants'; +import { DEFAULT_THEME } from '../lib/themes'; export default () => ({ currentProjectId: '', @@ -20,7 +21,7 @@ export default () => ({ entries: {}, viewer: viewerTypes.edit, delayViewerUpdated: false, - currentActivityView: activityBarViews.edit, + currentActivityView: leftSidebarViews.edit.name, unusedSeal: true, fileFindVisible: false, links: {}, @@ -32,4 +33,5 @@ export default () => ({ }, clientsidePreviewEnabled: false, renderWhitespaceInCode: false, + editorTheme: DEFAULT_THEME, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 47a2e6b5202..06e66da1069 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -163,7 +163,7 @@ export const createCommitPayload = ({ }); export const createNewMergeRequestUrl = (projectUrl, source, target) => - `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`; + `${projectUrl}/-/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`; const sortTreesByTypeAndName = (a, b) => { if (a.type === 'tree' && b.type === 'blob') { diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue index eb924609a8a..2fd92e009eb 100644 --- a/app/assets/javascripts/issuables_list/components/issuable.vue +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -3,7 +3,7 @@ * This is tightly coupled to projects/issues/_issue.html.haml, * any changes done to the haml need to be reflected here. */ -import { escape, isNumber } from 'underscore'; +import { escape, isNumber } from 'lodash'; import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { dateInWords, @@ -19,8 +19,6 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -const ISSUE_TOKEN = '#'; - export default { components: { Icon, @@ -119,8 +117,7 @@ export default { ); }, referencePath() { - // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301 - return `${ISSUE_TOKEN}${this.issuable.iid}`; + return this.issuable.references.relative; }, updatedDateString() { return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); @@ -230,7 +227,7 @@ export default { </div> <div class="issuable-info"> - <span>{{ referencePath }}</span> + <span class="js-ref-path">{{ referencePath }}</span> <span class="d-none d-sm-inline-block mr-1"> · diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 163849d3c40..d9168f57cc7 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { escape as esc, isEmpty } from 'lodash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { sprintf, __ } from '../../locale'; @@ -12,6 +12,11 @@ export default { type: Object, required: true, }, + deploymentCluster: { + type: Object, + required: false, + default: null, + }, iconStatus: { type: Object, required: true, @@ -38,7 +43,7 @@ export default { '%{startLink}%{name}%{endLink}', { startLink: `<a href="${this.deploymentStatus.environment.environment_path}" class="js-environment-link">`, - name: _.escape(this.deploymentStatus.environment.name), + name: esc(this.deploymentStatus.environment.name), endLink: '</a>', }, false, @@ -53,24 +58,24 @@ export default { return this.hasLastDeployment ? this.deploymentStatus.environment.last_deployment : {}; }, hasEnvironment() { - return !_.isEmpty(this.deploymentStatus.environment); + return !isEmpty(this.deploymentStatus.environment); }, lastDeploymentPath() { - return !_.isEmpty(this.lastDeployment.deployable) + return !isEmpty(this.lastDeployment.deployable) ? this.lastDeployment.deployable.build_path : ''; }, hasCluster() { - return this.hasLastDeployment && this.lastDeployment.cluster; + return Boolean(this.deploymentCluster) && Boolean(this.deploymentCluster.name); }, clusterNameOrLink() { if (!this.hasCluster) { return ''; } - const { name, path } = this.lastDeployment.cluster; - const escapedName = _.escape(name); - const escapedPath = _.escape(path); + const { name, path } = this.deploymentCluster; + const escapedName = esc(name); + const escapedPath = esc(path); if (!escapedPath) { return escapedName; @@ -86,6 +91,9 @@ export default { false, ); }, + kubernetesNamespace() { + return this.hasCluster ? this.deploymentCluster.kubernetes_namespace : null; + }, }, methods: { deploymentLink(name) { @@ -109,75 +117,153 @@ export default { ); }, lastEnvironmentMessage() { - const { environmentLink, clusterNameOrLink, hasCluster } = this; - - const message = hasCluster - ? __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.') - : __('This job is deployed to %{environmentLink}.'); - - return sprintf(message, { environmentLink, clusterNameOrLink }, false); + const { environmentLink, clusterNameOrLink, hasCluster, kubernetesNamespace } = this; + if (hasCluster) { + if (kubernetesNamespace) { + return sprintf( + __( + 'This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ), + { environmentLink, clusterNameOrLink, kubernetesNamespace }, + false, + ); + } + // we know the cluster but not the namespace + return sprintf( + __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.'), + { environmentLink, clusterNameOrLink }, + false, + ); + } + // not a cluster deployment + return sprintf(__('This job is deployed to %{environmentLink}.'), { environmentLink }, false); }, outOfDateEnvironmentMessage() { - const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this; + const { + hasLastDeployment, + hasCluster, + environmentLink, + clusterNameOrLink, + kubernetesNamespace, + } = this; if (hasLastDeployment) { - const message = hasCluster - ? __( - 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.', - ) - : __( - 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.', + const deploymentLink = this.deploymentLink(__('most recent deployment')); + if (hasCluster) { + if (kubernetesNamespace) { + return sprintf( + __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. View the %{deploymentLink}.', + ), + { environmentLink, clusterNameOrLink, kubernetesNamespace, deploymentLink }, + false, ); - + } + // we know the cluster but not the namespace + return sprintf( + __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.', + ), + { environmentLink, clusterNameOrLink, deploymentLink }, + false, + ); + } + // not a cluster deployment return sprintf( - message, - { - environmentLink, - clusterNameOrLink, - deploymentLink: this.deploymentLink(__('most recent deployment')), - }, + __( + 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.', + ), + { environmentLink, deploymentLink }, false, ); } - - const message = hasCluster - ? __( + // no last deployment, i.e. this is the first deployment + if (hasCluster) { + if (kubernetesNamespace) { + return sprintf( + __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ), + { environmentLink, clusterNameOrLink, kubernetesNamespace }, + false, + ); + } + // we know the cluster but not the namespace + return sprintf( + __( 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', - ) - : __('This job is an out-of-date deployment to %{environmentLink}.'); - + ), + { environmentLink, clusterNameOrLink }, + false, + ); + } + // not a cluster deployment return sprintf( - message, - { - environmentLink, - clusterNameOrLink, - }, + __('This job is an out-of-date deployment to %{environmentLink}.'), + { environmentLink }, false, ); }, creatingEnvironmentMessage() { - const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this; + const { + hasLastDeployment, + hasCluster, + environmentLink, + clusterNameOrLink, + kubernetesNamespace, + } = this; if (hasLastDeployment) { - const message = hasCluster - ? __( - 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.', - ) - : __( - 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.', + const deploymentLink = this.deploymentLink(__('latest deployment')); + if (hasCluster) { + if (kubernetesNamespace) { + return sprintf( + __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. This will overwrite the %{deploymentLink}.', + ), + { environmentLink, clusterNameOrLink, kubernetesNamespace, deploymentLink }, + false, ); - + } + // we know the cluster but not the namespace + return sprintf( + __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.', + ), + { environmentLink, clusterNameOrLink, deploymentLink }, + false, + ); + } + // not a cluster deployment return sprintf( - message, - { - environmentLink, - clusterNameOrLink, - deploymentLink: this.deploymentLink(__('latest deployment')), - }, + __( + 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.', + ), + { environmentLink, deploymentLink }, false, ); } - + // no last deployment, i.e. this is the first deployment + if (hasCluster) { + if (kubernetesNamespace) { + return sprintf( + __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ), + { environmentLink, clusterNameOrLink, kubernetesNamespace }, + false, + ); + } + // we know the cluster but not the namespace + return sprintf( + __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ), + { environmentLink, clusterNameOrLink }, + false, + ); + } + // not a cluster deployment return sprintf( __('This job is creating a deployment to %{environmentLink}.'), { environmentLink }, diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue index 8437ad89301..fc5e022f44a 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/erased_block.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import { GlLink } from '@gitlab/ui'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -21,7 +21,7 @@ export default { }, computed: { isErasedByUser() { - return !_.isEmpty(this.user); + return !isEmpty(this.user); }, }, }; diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 809b3d5f57e..0783d1157be 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; @@ -8,7 +8,6 @@ import { polyfillSticky } from '~/lib/utils/sticky'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import Callout from '~/vue_shared/components/callout.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import createStore from '../store'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; @@ -22,7 +21,6 @@ import { isNewJobLogActive } from '../store/utils'; export default { name: 'JobPageApp', - store: createStore(), components: { CiHeader, Callout, @@ -60,27 +58,15 @@ export default { required: false, default: null, }, - endpoint: { - type: String, - required: true, - }, terminalPath: { type: String, required: false, default: null, }, - pagePath: { - type: String, - required: true, - }, projectPath: { type: String, required: true, }, - logState: { - type: String, - required: true, - }, subscriptionsMoreMinutesUrl: { type: String, required: false, @@ -139,7 +125,7 @@ export default { // Once the job log is loaded, // fetch the stages for the dropdown on the sidebar job(newVal, oldVal) { - if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) { + if (isEmpty(oldVal) && !isEmpty(newVal.pipeline)) { const stages = this.job.pipeline.details.stages || []; const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage); @@ -159,16 +145,7 @@ export default { }, }, created() { - this.throttled = _.throttle(this.toggleScrollButtons, 100); - - this.setJobEndpoint(this.endpoint); - this.setTraceOptions({ - logState: this.logState, - pagePath: this.pagePath, - }); - - this.fetchJob(); - this.fetchTrace(); + this.throttled = throttle(this.toggleScrollButtons, 100); window.addEventListener('resize', this.onResize); window.addEventListener('scroll', this.updateScroll); @@ -176,22 +153,22 @@ export default { mounted() { this.updateSidebar(); }, - destroyed() { + beforeDestroy() { + this.stopPollingTrace(); + this.stopPolling(); window.removeEventListener('resize', this.onResize); window.removeEventListener('scroll', this.updateScroll); }, methods: { ...mapActions([ - 'setJobEndpoint', - 'setTraceOptions', - 'fetchJob', 'fetchJobsForStage', 'hideSidebar', 'showSidebar', 'toggleSidebar', - 'fetchTrace', 'scrollBottom', 'scrollTop', + 'stopPollingTrace', + 'stopPolling', 'toggleScrollButtons', 'toggleScrollAnimation', ]), @@ -223,7 +200,7 @@ export default { <div> <gl-loading-icon v-if="isLoading" - :size="2" + size="lg" class="js-job-loading qa-loading-animation prepend-top-20" /> @@ -279,6 +256,7 @@ export default { v-if="hasEnvironment" class="js-job-environment" :deployment-status="job.deployment_status" + :deployment-cluster="job.deployment_cluster" :icon-status="job.status" /> diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index c32a3cac7be..a23f30d571a 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { uniqueId } from 'lodash'; import { mapActions } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; @@ -19,7 +19,9 @@ export default { validator(value) { return ( value === null || - (_.has(value, 'path') && _.has(value, 'method') && _.has(value, 'button_title')) + (Object.prototype.hasOwnProperty.call(value, 'path') && + Object.prototype.hasOwnProperty.call(value, 'method') && + Object.prototype.hasOwnProperty.call(value, 'button_title')) ); }, }, @@ -78,7 +80,7 @@ export default { const newVariable = { key: this.key, secret_value: this.secretValue, - id: _.uniqueId(), + id: uniqueId(), }; this.variables.push(newVariable); diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 415fa46835b..f1683bc2195 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import { mapActions, mapState } from 'vuex'; import { GlLink, GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; @@ -84,10 +84,10 @@ export default { ); }, hasArtifact() { - return !_.isEmpty(this.job.artifact); + return !isEmpty(this.job.artifact); }, hasTriggers() { - return !_.isEmpty(this.job.trigger); + return !isEmpty(this.job.trigger); }, hasStages() { return ( @@ -119,6 +119,7 @@ export default { :class="retryButtonClass" :href="job.retry_path" data-method="post" + data-qa-selector="retry_button" rel="nofollow" >{{ __('Retry') }}</gl-link > diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 09f9647a680..ddcfc3d6db6 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -24,7 +24,7 @@ export default { }, computed: { hasRef() { - return !_.isEmpty(this.pipeline.ref); + return !isEmpty(this.pipeline.ref); }, isTriggeredByMergeRequest() { return Boolean(this.pipeline.merge_request); diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 9c35534523e..024a13ce102 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,11 +1,18 @@ import Vue from 'vue'; import JobApp from './components/job_app.vue'; +import createStore from './store'; export default () => { const element = document.getElementById('js-job-vue-app'); + const store = createStore(); + + // Let's start initializing the store (i.e. fetching data) right away + store.dispatch('init', element.dataset); + return new Vue({ el: element, + store, components: { JobApp, }, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 41cc5a181dc..f4030939f2c 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -14,6 +14,16 @@ import { scrollUp, } from '~/lib/utils/scroll_utils'; +export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { + dispatch('setJobEndpoint', endpoint); + dispatch('setTraceOptions', { + logState, + pagePath, + }); + + return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]); +}; + export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); export const setTraceOptions = ({ commit }, options) => commit(types.SET_TRACE_OPTIONS, options); @@ -147,7 +157,6 @@ export const toggleScrollisInBottom = ({ commit }, toggle) => { export const requestTrace = ({ commit }) => commit(types.REQUEST_TRACE); -let traceTimeout; export const fetchTrace = ({ dispatch, state }) => axios .get(`${state.traceEndpoint}/trace.json`, { @@ -157,24 +166,32 @@ export const fetchTrace = ({ dispatch, state }) => dispatch('toggleScrollisInBottom', isScrolledToBottom()); dispatch('receiveTraceSuccess', data); - if (!data.complete) { - traceTimeout = setTimeout(() => { - dispatch('fetchTrace'); - }, 4000); - } else { + if (data.complete) { dispatch('stopPollingTrace'); + } else if (!state.traceTimeout) { + dispatch('startPollingTrace'); } }) .catch(() => dispatch('receiveTraceError')); -export const stopPollingTrace = ({ commit }) => { +export const startPollingTrace = ({ dispatch, commit }) => { + const traceTimeout = setTimeout(() => { + commit(types.SET_TRACE_TIMEOUT, 0); + dispatch('fetchTrace'); + }, 4000); + + commit(types.SET_TRACE_TIMEOUT, traceTimeout); +}; + +export const stopPollingTrace = ({ state, commit }) => { + clearTimeout(state.traceTimeout); + commit(types.SET_TRACE_TIMEOUT, 0); commit(types.STOP_POLLING_TRACE); - clearTimeout(traceTimeout); }; + export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log); -export const receiveTraceError = ({ commit }) => { - commit(types.RECEIVE_TRACE_ERROR); - clearTimeout(traceTimeout); +export const receiveTraceError = ({ dispatch }) => { + dispatch('stopPollingTrace'); flash(__('An error occurred while fetching the job log.')); }; /** diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 406b1a2e375..3f02f924eed 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { isEmpty, isString } from 'lodash'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); @@ -7,15 +7,15 @@ export const hasUnmetPrerequisitesFailure = state => state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; export const shouldRenderCalloutMessage = state => - !_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message); + !isEmpty(state.job.status) && !isEmpty(state.job.callout_message); /** * When job has not started the key will be null * When job started the key will be a string with a date. */ -export const shouldRenderTriggeredLabel = state => _.isString(state.job.started); +export const shouldRenderTriggeredLabel = state => isString(state.job.started); -export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status); +export const hasEnvironment = state => !isEmpty(state.job.deployment_status); /** * Checks if it the job has trace. @@ -23,7 +23,7 @@ export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status); * @returns {Boolean} */ export const hasTrace = state => - state.job.has_trace || (!_.isEmpty(state.job.status) && state.job.status.group === 'running'); + state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); export const emptyStateIllustration = state => (state.job && state.job.status && state.job.status.illustration) || {}; @@ -38,8 +38,8 @@ export const emptyStateAction = state => * @returns {Boolean} */ export const shouldRenderSharedRunnerLimitWarning = state => - !_.isEmpty(state.job.runners) && - !_.isEmpty(state.job.runners.quota) && + !isEmpty(state.job.runners) && + !isEmpty(state.job.runners.quota) && state.job.runners.quota.used >= state.job.runners.quota.limit; export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete; diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js index 858fa3b73ab..6c4f1b5a191 100644 --- a/app/assets/javascripts/jobs/store/mutation_types.js +++ b/app/assets/javascripts/jobs/store/mutation_types.js @@ -10,7 +10,6 @@ export const DISABLE_SCROLL_BOTTOM = 'DISABLE_SCROLL_BOTTOM'; export const DISABLE_SCROLL_TOP = 'DISABLE_SCROLL_TOP'; export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM'; export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP'; -// TODO export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION'; export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; @@ -20,6 +19,7 @@ export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS'; export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR'; export const REQUEST_TRACE = 'REQUEST_TRACE'; +export const SET_TRACE_TIMEOUT = 'SET_TRACE_TIMEOUT'; export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE'; export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS'; export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR'; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 77c68cac4a6..6193d8d34ab 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -53,17 +53,14 @@ export default { state.isTraceComplete = log.complete || state.isTraceComplete; }, - /** - * Will remove loading animation - */ - [types.STOP_POLLING_TRACE](state) { - state.isTraceComplete = true; + [types.SET_TRACE_TIMEOUT](state, id) { + state.traceTimeout = id; }, /** * Will remove loading animation */ - [types.RECEIVE_TRACE_ERROR](state) { + [types.STOP_POLLING_TRACE](state) { state.isTraceComplete = true; }, diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index cdc1780f3d6..5a61828ec6d 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -22,6 +22,7 @@ export default () => ({ isTraceComplete: false, traceSize: 0, isTraceSizeVisible: false, + traceTimeout: 0, // used as a query parameter to fetch the trace traceState: null, diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 2c5278d16ae..b49fe9362c2 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -5,6 +5,14 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import csrf from '~/lib/utils/csrf'; +export const fetchPolicies = { + CACHE_FIRST: 'cache-first', + CACHE_AND_NETWORK: 'cache-and-network', + NETWORK_ONLY: 'network-only', + NO_CACHE: 'no-cache', + CACHE_ONLY: 'cache-only', +}; + export default (resolvers = {}, config = {}) => { let uri = `${gon.relative_url_root}/api/graphql`; @@ -32,5 +40,10 @@ export default (resolvers = {}, config = {}) => { }), resolvers, assumeImmutableResults: config.assumeImmutableResults, + defaultOptions: { + query: { + fetchPolicy: config.fetchPolicy || fetchPolicies.CACHE_FIRST, + }, + }, }); }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a2591180039..dd5a52fe1ce 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -327,7 +327,10 @@ export const getSelectedFragment = restrictToNode => { documentFragment.originalNodes.push(range.commonAncestorContainer); } } - if (documentFragment.textContent.length === 0) return null; + + if (documentFragment.textContent.length === 0 && documentFragment.children.length === 0) { + return null; + } return documentFragment; }; diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js new file mode 100644 index 00000000000..6d4e21cf386 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -0,0 +1,320 @@ +import dateformat from 'dateformat'; +import { pick, omit, isEqual, isEmpty } from 'lodash'; +import { secondsToMilliseconds } from './datetime_utility'; + +const MINIMUM_DATE = new Date(0); + +const DEFAULT_DIRECTION = 'before'; + +const durationToMillis = duration => { + if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) { + return secondsToMilliseconds(duration.seconds); + } + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + throw new Error('Invalid duration: only `seconds` is supported'); +}; + +const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration)); + +const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration)); + +const isValidDuration = duration => Boolean(duration && Number.isFinite(duration.seconds)); + +const isValidDateString = dateString => { + if (typeof dateString !== 'string' || !dateString.trim()) { + return false; + } + + try { + // dateformat throws error that can be caught. + // This is better than using `new Date()` + dateformat(dateString, 'isoUtcDateTime'); + return true; + } catch (e) { + return false; + } +}; + +const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => { + let startDate; + let endDate; + + if (direction === DEFAULT_DIRECTION) { + startDate = minDate; + endDate = anchorDate; + } else { + startDate = anchorDate; + endDate = maxDate; + } + + return { + startDate, + endDate, + }; +}; + +/** + * Converts a fixed range to a fixed range + * @param {Object} fixedRange - A range with fixed start and + * end (e.g. "midnight January 1st 2020 to midday January31st 2020") + */ +const convertFixedToFixed = ({ start, end }) => ({ + start, + end, +}); + +/** + * Converts an anchored range to a fixed range + * @param {Object} anchoredRange - A duration of time + * relative to a fixed point in time (e.g., "the 30 minutes + * before midnight January 1st 2020", or "the 2 days + * after midday on the 11th of May 2019") + */ +const convertAnchoredToFixed = ({ anchor, duration, direction }) => { + const anchorDate = new Date(anchor); + + const { startDate, endDate } = handleRangeDirection({ + minDate: dateMinusDuration(anchorDate, duration), + maxDate: datePlusDuration(anchorDate, duration), + direction, + anchorDate, + }); + + return { + start: startDate.toISOString(), + end: endDate.toISOString(), + }; +}; + +/** + * Converts a rolling change to a fixed range + * + * @param {Object} rollingRange - A time range relative to + * now (e.g., "last 2 minutes", or "next 2 days") + */ +const convertRollingToFixed = ({ duration, direction }) => { + // Use Date.now internally for easier mocking in tests + const now = new Date(Date.now()); + + return convertAnchoredToFixed({ + duration, + direction, + anchor: now.toISOString(), + }); +}; + +/** + * Converts an open range to a fixed range + * + * @param {Object} openRange - A time range relative + * to an anchor (e.g., "before midnight on the 1st of + * January 2020", or "after midday on the 11th of May 2019") + */ +const convertOpenToFixed = ({ anchor, direction }) => { + // Use Date.now internally for easier mocking in tests + const now = new Date(Date.now()); + + const { startDate, endDate } = handleRangeDirection({ + minDate: MINIMUM_DATE, + maxDate: now, + direction, + anchorDate: new Date(anchor), + }); + + return { + start: startDate.toISOString(), + end: endDate.toISOString(), + }; +}; + +/** + * Handles invalid date ranges + */ +const handleInvalidRange = () => { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + throw new Error('The input range does not have the right format.'); +}; + +const handlers = { + invalid: handleInvalidRange, + fixed: convertFixedToFixed, + anchored: convertAnchoredToFixed, + rolling: convertRollingToFixed, + open: convertOpenToFixed, +}; + +/** + * Validates and returns the type of range + * + * @param {Object} Date time range + * @returns {String} `key` value for one of the handlers + */ +export function getRangeType(range) { + const { start, end, anchor, duration } = range; + + if ((start || end) && !anchor && !duration) { + return isValidDateString(start) && isValidDateString(end) ? 'fixed' : 'invalid'; + } + if (anchor && duration) { + return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid'; + } + if (duration && !anchor) { + return isValidDuration(duration) ? 'rolling' : 'invalid'; + } + if (anchor && !duration) { + return isValidDateString(anchor) ? 'open' : 'invalid'; + } + return 'invalid'; +} + +/** + * convertToFixedRange Transforms a `range of time` into a `fixed range of time`. + * + * The following types of a `ranges of time` can be represented: + * + * Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020") + * Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019") + * Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days") + * Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019") + * + * @param {Object} dateTimeRange - A Time Range representation + * It contains the data needed to create a fixed time range plus + * a label (recommended) to indicate the range that is covered. + * + * A definition via a TypeScript notation is presented below: + * + * + * type Duration = { // A duration of time, always in seconds + * seconds: number; + * } + * + * type Direction = 'before' | 'after'; // Direction of time relative to an anchor + * + * type FixedRange = { + * start: ISO8601; + * end: ISO8601; + * label: string; + * } + * + * type AnchoredRange = { + * anchor: ISO8601; + * duration: Duration; + * direction: Direction; // defaults to 'before' + * label: string; + * } + * + * type RollingRange = { + * duration: Duration; + * direction: Direction; // defaults to 'before' + * label: string; + * } + * + * type OpenRange = { + * anchor: ISO8601; + * direction: Direction; // defaults to 'before' + * label: string; + * } + * + * type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange; + * + * + * @returns {FixedRange} An object with a start and end in ISO8601 format. + */ +export const convertToFixedRange = dateTimeRange => + handlers[getRangeType(dateTimeRange)](dateTimeRange); + +/** + * Returns a copy of the object only with time range + * properties relevant to time range calculation. + * + * Filtered properties are: + * - 'start' + * - 'end' + * - 'anchor' + * - 'duration' + * - 'direction': if direction is already the default, its removed. + * + * @param {Object} timeRange - A time range object + * @returns Copy of time range + */ +const pruneTimeRange = timeRange => { + const res = pick(timeRange, ['start', 'end', 'anchor', 'duration', 'direction']); + if (res.direction === DEFAULT_DIRECTION) { + return omit(res, 'direction'); + } + return res; +}; + +/** + * Returns true if the time ranges are equal according to + * the time range calculation properties + * + * @param {Object} timeRange - A time range object + * @param {Object} other - Time range object to compare with. + * @returns true if the time ranges are equal, false otherwise + */ +export const isEqualTimeRanges = (timeRange, other) => { + const tr1 = pruneTimeRange(timeRange); + const tr2 = pruneTimeRange(other); + return isEqual(tr1, tr2); +}; + +/** + * Searches for a time range in a array of time ranges using + * only the properies relevant to time ranges calculation. + * + * @param {Object} timeRange - Time range to search (needle) + * @param {Array} timeRanges - Array of time tanges (haystack) + */ +export const findTimeRange = (timeRange, timeRanges) => + timeRanges.find(element => isEqualTimeRanges(element, timeRange)); + +// Time Ranges as URL Parameters Utils + +/** + * List of possible time ranges parameters + */ +export const timeRangeParamNames = ['start', 'end', 'anchor', 'duration_seconds', 'direction']; + +/** + * Converts a valid time range to a flat key-value pairs object. + * + * Duration is flatted to avoid having nested objects. + * + * @param {Object} A time range + * @returns key-value pairs object that can be used as parameters in a URL. + */ +export const timeRangeToParams = timeRange => { + let params = pruneTimeRange(timeRange); + if (timeRange.duration) { + const durationParms = {}; + Object.keys(timeRange.duration).forEach(key => { + durationParms[`duration_${key}`] = timeRange.duration[key].toString(); + }); + params = { ...durationParms, ...params }; + params = omit(params, 'duration'); + } + return params; +}; + +/** + * Converts a valid set of flat params to a time range object + * + * Parameters that are not part of time range object are ignored. + * + * @param {params} params - key-value pairs object. + */ +export const timeRangeFromParams = params => { + const timeRangeParams = pick(params, timeRangeParamNames); + let range = Object.entries(timeRangeParams).reduce((acc, [key, val]) => { + // unflatten duration + if (key.startsWith('duration_')) { + acc.duration = acc.duration || {}; + acc.duration[key.slice('duration_'.length)] = parseInt(val, 10); + return acc; + } + return { [key]: val, ...acc }; + }, {}); + range = pruneTimeRange(range); + return !isEmpty(range) ? range : null; +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 1c7d59054dc..08a77966bbd 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -19,6 +19,7 @@ const httpStatusCodes = { UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, + CONFLICT: 409, GONE: 410, UNPROCESSABLE_ENTITY: 422, SERVICE_UNAVAILABLE: 503, diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index d48678c21f6..1ff4f7bab97 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,6 +1,14 @@ const PATH_SEPARATOR = '/'; const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); +const SHA_REGEX = /[\da-f]{40}/gi; + +// Reset the cursor in a Regex so that multiple uses before a recompile don't fail +function resetRegExp(regex) { + regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */ + + return regex; +} // Returns a decoded url parameter value // - Treats '+' as '%20' @@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) { return hash && hash.includes(hashName); } +export function urlContainsSha({ url = String(window.location) } = {}) { + return resetRegExp(SHA_REGEX).test(url); +} + +export function getShaFromUrl({ url = String(window.location) } = {}) { + let sha = null; + + if (urlContainsSha({ url })) { + [sha] = url.match(resetRegExp(SHA_REGEX)); + } + + return sha; +} + /** * Apply the fragment to the given url by returning a new url string that includes * the fragment. If the given url already contains a fragment, the original fragment @@ -144,7 +166,7 @@ export const setUrlFragment = (url, fragment) => { export function visitUrl(url, external = false) { if (external) { - // Simulate `target="blank" rel="noopener noreferrer"` + // Simulate `target="_blank" rel="noopener noreferrer"` // See https://mathiasbynens.github.io/rel-noopener/ const otherWindow = window.open(); otherWindow.opener = null; @@ -154,6 +176,16 @@ export function visitUrl(url, external = false) { } } +export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) { + if (win.history) { + if (replace) { + win.history.replaceState(state, title, url); + } else { + win.history.pushState(state, title, url); + } + } +} + export function refreshCurrentPage() { visitUrl(window.location.href); } @@ -162,12 +194,14 @@ export function redirectTo(url) { return window.location.assign(url); } +export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); + export function webIDEUrl(route = undefined) { let returnUrl = `${gon.relative_url_root || ''}/-/ide/`; if (route) { returnUrl += `project${route.replace(new RegExp(`^${gon.relative_url_root || ''}`), '')}`; } - return returnUrl; + return escapeFileUrl(returnUrl); } /** @@ -281,4 +315,6 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f return urlObj.toString(); }; -export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); +export function urlIsDifferent(url, compare = String(window.location)) { + return url !== compare; +} diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js index 37b17f0fe23..390294afcb7 100644 --- a/app/assets/javascripts/lib/utils/webpack.js +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -8,7 +8,7 @@ export function resetServiceWorkersPublicPath() { // see: https://webpack.js.org/guides/public-path/ const relativeRootPath = (gon && gon.relative_url_root) || ''; const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/'); - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase // monaco-editor-webpack-plugin currently (incorrectly) references the // public path as a property of `window`. Once this is fixed upstream we diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index d755e7e8cdb..5b645b032ed 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -35,6 +35,8 @@ import initPerformanceBar from './performance_bar'; import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; +import initBroadcastNotifications from './broadcast_notification'; +import PersistentUserCallout from './persistent_user_callout'; import { initUserTracking } from './tracking'; import { __ } from './locale'; @@ -105,6 +107,10 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initUserTracking(); + initBroadcastNotifications(); + + const recoverySettingsCallout = document.querySelector('.js-recovery-settings-callout'); + PersistentUserCallout.factory(recoverySettingsCallout); if (document.querySelector('.search')) initSearchAutocomplete(); @@ -195,9 +201,15 @@ document.addEventListener('DOMContentLoaded', () => { }); if (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') { - const $rightSidebar = $('aside.right-sidebar, .layout-page'); + const $rightSidebar = $('aside.right-sidebar'); + const $layoutPage = $('.layout-page'); - $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + if ($rightSidebar.length > 0) { + $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + $layoutPage.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } else { + $layoutPage.removeClass('right-sidebar-expanded right-sidebar-collapsed'); + } } // prevent default action for disabled buttons diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index f93dbcd4c47..683fe8b0b14 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -29,6 +29,7 @@ const initManualOrdering = (draggableSelector = 'li.issue') => { issueList, getBoardSortableDefaultOptions({ scroll: true, + fallbackTolerance: 1, dataIdAttr: 'data-id', fallbackOnBody: false, group: { diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js index e7fcc183715..25c357b6073 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js @@ -1,4 +1,4 @@ -/* eslint-disable no-param-reassign, camelcase, no-nested-ternary, no-continue */ +/* eslint-disable no-param-reassign, babel/camelcase, no-nested-ternary, no-continue */ import $ from 'jquery'; import Vue from 'vue'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 3a7ade5ad94..6c794c1d324 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -24,7 +24,7 @@ function MergeRequest(opts) { this.initCommitMessageListeners(); this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); - if ($('a.btn-close').length) { + if ($('.description.js-task-list-container').length) { this.taskList = new TaskList({ dataType: 'merge_request', fieldName: 'description', diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 96c4741fc2e..87de58443e0 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -32,17 +32,17 @@ import { __ } from './locale'; // // <ul class="nav-links merge-request-tabs"> // <li class="notes-tab active"> -// <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1"> +// <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/-/merge_requests/1"> // Discussion // </a> // </li> // <li class="commits-tab"> -// <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits"> +// <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/-/merge_requests/1/commits"> // Commits // </a> // </li> // <li class="diffs-tab"> -// <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs"> +// <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/-/merge_requests/1/diffs"> // Diffs // </a> // </li> @@ -260,17 +260,17 @@ export default class MergeRequestTabs { // // Examples: // - // location.pathname # => "/namespace/project/merge_requests/1" + // location.pathname # => "/namespace/project/-/merge_requests/1" // setCurrentAction('diffs') - // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // location.pathname # => "/namespace/project/-/merge_requests/1/diffs" // - // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // location.pathname # => "/namespace/project/-/merge_requests/1/diffs" // setCurrentAction('show') - // location.pathname # => "/namespace/project/merge_requests/1" + // location.pathname # => "/namespace/project/-/merge_requests/1" // - // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // location.pathname # => "/namespace/project/-/merge_requests/1/diffs" // setCurrentAction('commits') - // location.pathname # => "/namespace/project/merge_requests/1/commits" + // location.pathname # => "/namespace/project/-/merge_requests/1/commits" // // Returns the new URL String setCurrentAction(action) { diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 33e9b1c4e46..e5acaaf9366 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { debounce } from 'lodash'; import { __ } from '~/locale'; import Flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -62,7 +62,7 @@ export default class MirrorRepos { } registerUpdateListeners() { - this.debouncedUpdateUrl = _.debounce(() => this.updateUrl(), 200); + this.debouncedUpdateUrl = debounce(() => this.updateUrl(), 200); this.$urlInput.on('input', () => this.debouncedUpdateUrl()); this.$protectedBranchesInput.on('change', () => this.updateProtectedBranches()); this.$table.on('click', '.js-delete-mirror', event => this.deleteMirror(event)); diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index bb5ae6ce2d1..550e1aeeb9c 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import Flash from '~/flash'; @@ -162,7 +162,7 @@ export default class SSHMirror { const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list'); let fingerprints = ''; sshHostKeys.fingerprints.forEach(fingerprint => { - const escFingerprints = _.escape(fingerprint.fingerprint); + const escFingerprints = esc(fingerprint.fingerprint); fingerprints += `<code>${escFingerprints}</code>`; }); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 64704701d1a..447f8845506 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -1,5 +1,5 @@ <script> -import { flatten, isNumber } from 'underscore'; +import { flattenDeep, isNumber } from 'lodash'; import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; @@ -77,7 +77,7 @@ export default { * This offset is the lowest value. */ yOffset() { - const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y))); + const values = flattenDeep(this.series.map(ser => ser.data.map(([, y]) => y))); const min = values.length ? Math.floor(Math.min(...values)) : 0; return min < 0 ? -min : 0; }, @@ -127,7 +127,6 @@ export default { }); const yAxisWithOffset = { - name: this.yAxisLabel, axisLabel: { formatter: num => roundOffFloat(num - this.yOffset, 3).toString(), }, @@ -162,6 +161,7 @@ export default { }), ); } + return { yAxis: yAxisWithOffset, series: boundarySeries }; }, }, diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index eb407ad1d7f..0acdfe7675c 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -1,6 +1,6 @@ <script> +import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { chartHeight } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -10,24 +10,21 @@ export default { components: { GlColumnChart, }, - inheritAttrs: false, + directives: { + GlResizeObserverDirective, + }, props: { graphData: { type: Object, required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, - }, }, data() { return { width: 0, height: chartHeight, svgs: {}, - debouncedResizeCallback: {}, }; }, computed: { @@ -68,15 +65,7 @@ export default { }; }, }, - watch: { - containerWidth: 'onResize', - }, - beforeDestroy() { - window.removeEventListener('resize', this.debouncedResizeCallback); - }, created() { - this.debouncedResizeCallback = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', this.debouncedResizeCallback); this.setSvg('scroll-handle'); }, methods: { @@ -84,6 +73,7 @@ export default { return `${query.label}`; }, onResize() { + if (!this.$refs.columnChart) return; const { width } = this.$refs.columnChart.$el.getBoundingClientRect(); this.width = width; }, @@ -100,7 +90,7 @@ export default { }; </script> <template> - <div class="prometheus-graph"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index 6ab5aaeba1d..881904cbd0c 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -1,26 +1,29 @@ <script> +import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlHeatmap } from '@gitlab/ui/dist/charts'; import dateformat from 'dateformat'; import PrometheusHeader from '../shared/prometheus_header.vue'; -import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; import { graphDataValidatorForValues } from '../../utils'; export default { components: { GlHeatmap, - ResizableChartContainer, PrometheusHeader, }, + directives: { + GlResizeObserverDirective, + }, props: { graphData: { type: Object, required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, - }, + }, + data() { + return { + width: 0, + }; }, computed: { chartData() { @@ -52,22 +55,27 @@ export default { return this.graphData.metrics[0]; }, }, + methods: { + onResize() { + if (this.$refs.heatmapChart) return; + const { width } = this.$refs.heatmapChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, }; </script> <template> - <div class="prometheus-graph col-12 col-lg-6"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph col-12 col-lg-6"> <prometheus-header :graph-title="graphData.title" /> - <resizable-chart-container> - <gl-heatmap - ref="heatmapChart" - v-bind="$attrs" - :data-series="chartData" - :x-axis-name="xAxisName" - :y-axis-name="yAxisName" - :x-axis-labels="xAxisLabels" - :y-axis-labels="yAxisLabels" - :width="containerWidth" - /> - </resizable-chart-container> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="width" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index e75ddb05808..3368be4df75 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -19,8 +19,21 @@ export default { queryInfo() { return this.graphData.metrics[0]; }, - engineeringNotation() { - return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`; + queryResult() { + return this.queryInfo.result[0]?.value[1]; + }, + /** + * This method formats the query result from a promQL expression + * allowing a user to format the data in percentile values + * by using the `max_value` inner property from the graphData prop + * @returns {(String)} + */ + statValue() { + const chartValue = this.graphData?.max_value + ? (this.queryResult / Number(this.graphData.max_value)) * 100 + : this.queryResult; + + return `${roundOffFloat(chartValue, 1)}${this.queryInfo.unit}`; }, graphTitle() { return this.queryInfo.label; @@ -33,6 +46,6 @@ export default { <div class="prometheus-graph-header"> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5> </div> - <gl-single-stat :value="engineeringNotation" :title="graphTitle" variant="success" /> + <gl-single-stat :value="statValue" :title="graphTitle" variant="success" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue new file mode 100644 index 00000000000..55ae4a3bdb2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -0,0 +1,103 @@ +<script> +import { GlResizeObserverDirective } from '@gitlab/ui'; +import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { chartHeight } from '../../constants'; +import { graphDataValidatorForValues } from '../../utils'; + +export default { + components: { + GlStackedColumnChart, + }, + directives: { + GlResizeObserverDirective, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + }, + data() { + return { + width: 0, + height: chartHeight, + svgs: {}, + }; + }, + computed: { + chartData() { + return this.graphData.metrics.map(metric => metric.result[0].values.map(val => val[1])); + }, + xAxisTitle() { + return this.graphData.x_label !== undefined ? this.graphData.x_label : ''; + }, + yAxisTitle() { + return this.graphData.y_label !== undefined ? this.graphData.y_label : ''; + }, + xAxisType() { + return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category'; + }, + groupBy() { + return this.graphData.metrics[0].result[0].values.map(val => val[0]); + }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, + chartOptions() { + return { + dataZoom: this.dataZoomConfig, + }; + }, + seriesNames() { + return this.graphData.metrics.map(metric => metric.series_name); + }, + }, + created() { + this.setSvg('scroll-handle'); + }, + methods: { + setSvg(name) { + getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(e => { + // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings + console.error('SVG could not be rendered correctly: ', e); + }); + }, + onResize() { + if (!this.$refs.chart) return; + const { width } = this.$refs.chart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> +<template> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-stacked-column-chart + ref="chart" + v-bind="$attrs" + :data="chartData" + :option="chartOptions" + :x-axis-title="xAxisTitle" + :y-axis-title="yAxisTitle" + :x-axis-type="xAxisType" + :group-by="groupBy" + :width="width" + :height="height" + :series-names="seriesNames" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 0d442f14aea..d2b1e4da3fd 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { omit, throttle } from 'lodash'; import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; @@ -14,10 +14,29 @@ import { lineWidths, symbolSizes, dateFormats, + chartColorValues, } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; +/** + * A "virtual" coordinates system for the deployment icons. + * Deployment icons are displayed along the [min, max] + * range at height `pos`. + */ +const deploymentYAxisCoords = { + min: 0, + pos: 3, // 3% height of chart's grid + max: 100, +}; + +const THROTTLED_DATAZOOM_WAIT = 1000; // miliseconds +const timestampToISODate = timestamp => new Date(timestamp).toISOString(); + +const events = { + datazoom: 'datazoom', +}; + export default { components: { GlAreaChart, @@ -98,6 +117,7 @@ export default { height: chartHeight, svgs: {}, primaryColor: null, + throttledDatazoom: null, }; }, computed: { @@ -105,7 +125,7 @@ export default { // Transforms & supplements query data to render appropriate labels & styles // Input: [{ queryAttributes1 }, { queryAttributes2 }] // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] - return this.graphData.metrics.reduce((acc, query) => { + return this.graphData.metrics.reduce((acc, query, i) => { const { appearance } = query; const lineType = appearance && appearance.line && appearance.line.type @@ -126,7 +146,7 @@ export default { lineStyle: { type: lineType, width: lineWidth, - color: this.primaryColor, + color: chartColorValues[i % chartColorValues.length], }, showSymbol: false, areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, @@ -137,28 +157,52 @@ export default { }, []); }, chartOptionSeries() { - return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []); + return (this.option.series || []).concat( + this.deploymentSeries ? [this.deploymentSeries] : [], + ); }, chartOptions() { - const option = _.omit(this.option, 'series'); - return { - series: this.chartOptionSeries, - xAxis: { - name: __('Time'), - type: 'time', - axisLabel: { - formatter: date => dateFormat(date, dateFormats.timeOfDay), - }, - axisPointer: { - snap: true, - }, + const { yAxis, xAxis } = this.option; + const option = omit(this.option, ['series', 'yAxis', 'xAxis']); + + const dataYAxis = { + name: this.yAxisLabel, + nameGap: 50, // same as gitlab-ui's default + nameLocation: 'center', // same as gitlab-ui's default + boundaryGap: [0.1, 0.1], + scale: true, + axisLabel: { + formatter: num => roundOffFloat(num, 3).toString(), }, - yAxis: { - name: this.yAxisLabel, - axisLabel: { - formatter: num => roundOffFloat(num, 3).toString(), - }, + ...yAxis, + }; + + const deploymentsYAxis = { + show: false, + min: deploymentYAxisCoords.min, + max: deploymentYAxisCoords.max, + axisLabel: { + // formatter fn required to trigger tooltip re-positioning + formatter: () => {}, }, + }; + + const timeXAxis = { + name: __('Time'), + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, dateFormats.timeOfDay), + }, + axisPointer: { + snap: true, + }, + ...xAxis, + }; + + return { + series: this.chartOptionSeries, + xAxis: timeXAxis, + yAxis: [dataYAxis, deploymentsYAxis], dataZoom: [this.dataZoomConfig], ...option, }; @@ -209,7 +253,7 @@ export default { id, createdAt: created_at, sha, - commitUrl: `${this.projectPath}/commit/${sha}`, + commitUrl: `${this.projectPath}/-/commit/${sha}`, tag, tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null, ref: ref.name, @@ -220,10 +264,16 @@ export default { return acc; }, []); }, - scatterSeries() { + deploymentSeries() { return { type: graphTypes.deploymentData, - data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + + yAxisIndex: 1, // deploymentsYAxis index + data: this.recentDeployments.map(deployment => [ + deployment.createdAt, + deploymentYAxisCoords.pos, + ]), + symbol: this.svgs.rocket, symbolSize: symbolSizes.default, itemStyle: { @@ -245,6 +295,11 @@ export default { this.setSvg('rocket'); this.setSvg('scroll-handle'); }, + destroyed() { + if (this.throttledDatazoom) { + this.throttledDatazoom.cancel(); + } + }, methods: { formatLegendLabel(query) { return `${query.label}`; @@ -252,6 +307,7 @@ export default { formatTooltipText(params) { this.tooltip.title = dateFormat(params.value, dateFormats.default); this.tooltip.content = []; + params.seriesData.forEach(dataPoint => { if (dataPoint.value) { const [xVal, yVal] = dataPoint.value; @@ -287,8 +343,39 @@ export default { console.error('SVG could not be rendered correctly: ', e); }); }, - onChartUpdated(chart) { - [this.primaryColor] = chart.getOption().color; + onChartUpdated(eChart) { + [this.primaryColor] = eChart.getOption().color; + }, + + onChartCreated(eChart) { + // Emit a datazoom event that corresponds to the eChart + // `datazoom` event. + + if (this.throttledDatazoom) { + // Chart can be created multiple times in this component's + // lifetime, remove previous handlers every time + // chart is created. + this.throttledDatazoom.cancel(); + } + + // Emitting is throttled to avoid flurries of calls when + // the user changes or scrolls the zoom bar. + this.throttledDatazoom = throttle( + () => { + const { startValue, endValue } = eChart.getOption().dataZoom[0]; + this.$emit(events.datazoom, { + start: timestampToISODate(startValue), + end: timestampToISODate(endValue), + }); + }, + THROTTLED_DATAZOOM_WAIT, + { + leading: false, + }, + ); + + eChart.off('datazoom'); + eChart.on('datazoom', this.throttledDatazoom); }, onResize() { if (!this.$refs.chart) return; @@ -311,7 +398,10 @@ export default { <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip"> {{ graphData.title }} </gl-tooltip> - <div class="prometheus-graph-widgets js-graph-widgets flex-fill"> + <div + class="prometheus-graph-widgets js-graph-widgets flex-fill" + data-qa-selector="prometheus_graph_widgets" + > <slot></slot> </div> </div> @@ -328,6 +418,7 @@ export default { :height="height" :average-text="legendAverageText" :max-text="legendMaxText" + @created="onChartCreated" @updated="onChartUpdated" > <template v-if="tooltip.isDeployment"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b03ee12aef3..79f32b357fc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,34 +1,37 @@ <script> -import _ from 'underscore'; +import { debounce, pickBy } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import { GlButton, GlDropdown, GlDropdownItem, + GlDropdownHeader, + GlDropdownDivider, GlFormGroup, GlModal, + GlLoadingIcon, + GlSearchBoxByType, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; -import Icon from '~/vue_shared/components/icon.vue'; -import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; +import Icon from '~/vue_shared/components/icon.vue'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import DateTimePicker from './date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; import DashboardsDropdown from './dashboards_dropdown.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getTimeDiff, getAddMetricTrackingOptions } from '../utils'; -import { metricStates } from '../constants'; - -const defaultTimeDiff = getTimeDiff(); +import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils'; +import { defaultTimeRange, timeRanges, metricStates } from '../constants'; export default { components: { @@ -37,7 +40,11 @@ export default { Icon, GlButton, GlDropdown, + GlLoadingIcon, GlDropdownItem, + GlDropdownHeader, + GlDropdownDivider, + GlSearchBoxByType, GlFormGroup, GlModal, @@ -52,6 +59,7 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, + mixins: [glFeatureFlagsMixin()], props: { externalDashboardUrl: { type: String, @@ -63,6 +71,11 @@ export default { required: false, default: true, }, + showHeader: { + type: Boolean, + required: false, + default: true, + }, showPanels: { type: Boolean, required: false, @@ -88,6 +101,11 @@ export default { type: String, required: true, }, + logsPath: { + type: String, + required: false, + default: invalidUrl, + }, defaultBranch: { type: String, required: true, @@ -121,10 +139,6 @@ export default { type: String, required: true, }, - environmentsEndpoint: { - type: String, - required: true, - }, currentEnvironmentName: { type: String, required: true, @@ -184,9 +198,9 @@ export default { return { state: 'gettingStarted', formIsValid: null, - startDate: getParameterValues('start')[0] || defaultTimeDiff.start, - endDate: getParameterValues('end')[0] || defaultTimeDiff.end, + selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, hasValidDates: true, + timeRanges, isRearrangingPanels: false, }; }, @@ -198,17 +212,15 @@ export default { 'dashboard', 'emptyState', 'showEmptyState', - 'environments', 'deploymentData', 'useDashboardEndpoint', 'allDashboards', 'additionalPanelTypesEnabled', + 'environmentsLoading', ]), - ...mapGetters('monitoringDashboard', ['getMetricStates']), + ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']), firstDashboard() { - return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 - ? this.allDashboards[0] - : {}; + return this.allDashboards.length > 0 ? this.allDashboards[0] : {}; }, selectedDashboard() { return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; @@ -227,34 +239,37 @@ export default { this.externalDashboardUrl.length ); }, + shouldShowEnvironmentsDropdownNoMatchedMsg() { + return !this.environmentsLoading && this.filteredEnvironments.length === 0; + }, }, created() { this.setEndpoints({ metricsEndpoint: this.metricsEndpoint, - environmentsEndpoint: this.environmentsEndpoint, deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, dashboardsEndpoint: this.dashboardsEndpoint, currentDashboard: this.currentDashboard, projectPath: this.projectPath, + logsPath: this.logsPath, }); }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); } else { - this.fetchData({ - start: this.startDate, - end: this.endDate, - }); + this.setTimeRange(this.selectedTimeRange); + this.fetchData(); } }, methods: { ...mapActions('monitoringDashboard', [ + 'setTimeRange', 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', 'setPanelGroupMetrics', + 'filterEnvironments', ]), updatePanels(key, panels) { this.setPanelGroupMetrics({ @@ -269,8 +284,8 @@ export default { }); }, - onDateTimePickerApply(params) { - redirectTo(mergeUrlParams(params, window.location.href)); + onDateTimePickerInput(timeRange) { + redirectTo(timeRangeToUrl(timeRange)); }, onDateTimePickerInvalid() { createFlash( @@ -278,13 +293,13 @@ export default { 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', ), ); - this.startDate = defaultTimeDiff.start; - this.endDate = defaultTimeDiff.end; + // As a fallback, switch to default time range instead + this.selectedTimeRange = defaultTimeRange; }, generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; - const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); + const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null); return mergeUrlParams(params, window.location.href); }, hideAddMetricModal() { @@ -296,6 +311,9 @@ export default { setFormValidity(isValid) { this.formIsValid = isValid; }, + debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { + this.filterEnvironments(searchTerm); + }, 500), submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, @@ -342,64 +360,94 @@ export default { </script> <template> - <div class="prometheus-graphs"> - <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light"> + <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> + <div + v-if="showHeader" + ref="prometheusGraphsHeader" + class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light" + > <div class="row"> - <template v-if="environmentsEndpoint"> - <gl-form-group - :label="__('Dashboard')" - label-size="sm" - label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-6 col-lg-2" - > - <dashboards-dropdown - id="monitor-dashboards-dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" - @selectDashboard="selectDashboard($event)" - /> - </gl-form-group> + <gl-form-group + :label="__('Dashboard')" + label-size="sm" + label-for="monitor-dashboards-dropdown" + class="col-sm-12 col-md-6 col-lg-2" + > + <dashboards-dropdown + id="monitor-dashboards-dropdown" + class="mb-0 d-flex" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> + </gl-form-group> - <gl-form-group - :label="s__('Metrics|Environment')" - label-size="sm" - label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-6 col-lg-2" + <gl-form-group + :label="s__('Metrics|Environment')" + label-size="sm" + label-for="monitor-environments-dropdown" + class="col-sm-6 col-md-6 col-lg-2" + > + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + data-qa-selector="environments_dropdown" + class="mb-0 d-flex" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" > - <gl-dropdown - id="monitor-environments-dropdown" - class="mb-0 d-flex js-environments-dropdown" - toggle-class="dropdown-menu-toggle" - :text="currentEnvironmentName" - :disabled="environments.length === 0" - > - <gl-dropdown-item - v-for="environment in environments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{ + __('Environment') + }}</gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item + > + </div> + <div + v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" + ref="monitorEnvironmentsDropdownMsg" + class="text-secondary no-matches-message" > - </gl-dropdown> - </gl-form-group> + {{ __('No matching results') }} + </div> + </div> + </gl-dropdown> + </gl-form-group> - <gl-form-group - :label="s__('Metrics|Show last')" - label-size="sm" - label-for="monitor-time-window-dropdown" - class="col-sm-6 col-md-6 col-lg-4" - > - <date-time-picker - :start="startDate" - :end="endDate" - @apply="onDateTimePickerApply" - @invalid="onDateTimePickerInvalid" - /> - </gl-form-group> - </template> + <gl-form-group + :label="s__('Metrics|Show last')" + label-size="sm" + label-for="monitor-time-window-dropdown" + class="col-sm-6 col-md-6 col-lg-4" + > + <date-time-picker + ref="dateTimePicker" + :value="selectedTimeRange" + :options="timeRanges" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </gl-form-group> <gl-form-group v-if="hasHeaderButtons" @@ -413,18 +461,16 @@ export default { variant="default" class="mr-2 mt-1 js-rearrange-button" @click="toggleRearrangingPanels" + >{{ __('Arrange charts') }}</gl-button > - {{ __('Arrange charts') }} - </gl-button> <gl-button v-if="addingMetricsAvailable" ref="addMetricBtn" v-gl-modal="$options.addMetric.modalId" variant="outline-success" class="mr-2 mt-1" + >{{ $options.addMetric.title }}</gl-button > - {{ $options.addMetric.title }} - </gl-button> <gl-modal v-if="addingMetricsAvailable" ref="addMetricModal" @@ -446,9 +492,8 @@ export default { :disabled="!formIsValid" variant="success" @click="submitCustomMetricsForm" + >{{ __('Save changes') }}</gl-button > - {{ __('Save changes') }} - </gl-button> </div> </gl-modal> @@ -456,9 +501,8 @@ export default { v-if="selectedDashboard.can_edit" class="mt-1 js-edit-link" :href="selectedDashboard.project_blob_path" + >{{ __('Edit dashboard') }}</gl-button > - {{ __('Edit dashboard') }} - </gl-button> <gl-button v-if="externalDashboardUrl.length" @@ -484,44 +528,41 @@ export default { :show-panels="showPanels" :collapse-group="collapseGroup(groupData.key)" > - <div v-if="!groupSingleEmptyState(groupData.key)"> - <vue-draggable - :value="groupData.panels" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" - @input="updatePanels(groupData.key, $event)" + <vue-draggable + v-if="!groupSingleEmptyState(groupData.key)" + :value="groupData.panels" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updatePanels(groupData.key, $event)" + > + <div + v-for="(graphData, graphIndex) in groupData.panels" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" > - <div - v-for="(graphData, graphIndex) in groupData.panels" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removePanel(groupData.key, groupData.panels, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> - - <panel-type - :clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - :graph-data="graphData" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removePanel(groupData.key, groupData.panels, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> + <icon name="close" /> + </a> </div> + + <panel-type + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" + /> </div> - </vue-draggable> - </div> + </div> + </vue-draggable> <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> <group-empty-state ref="empty-group" diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 6d93eee0b4f..8f3e0a6ec75 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -4,11 +4,14 @@ import { GlAlert, GlDropdown, GlDropdownItem, + GlDropdownHeader, GlDropdownDivider, + GlSearchBoxByType, GlModal, GlLoadingIcon, GlModalDirective, } from '@gitlab/ui'; +import { s__ } from '~/locale'; import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; const events = { @@ -20,7 +23,9 @@ export default { GlAlert, GlDropdown, GlDropdownItem, + GlDropdownHeader, GlDropdownDivider, + GlSearchBoxByType, GlModal, GlLoadingIcon, DuplicateDashboardForm, @@ -44,6 +49,7 @@ export default { alert: null, loading: false, form: {}, + searchTerm: '', }; }, computed: { @@ -54,6 +60,17 @@ export default { selectedDashboardText() { return this.selectedDashboard.display_name; }, + filteredDashboards() { + return this.allDashboards.filter(({ display_name }) => + display_name.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + }, + shouldShowNoMsgContainer() { + return this.filteredDashboards.length === 0; + }, + okButtonText() { + return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); + }, }, methods: { ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), @@ -95,45 +112,70 @@ export default { }; </script> <template> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText"> - <gl-dropdown-item - v-for="dashboard in allDashboards" - :key="dashboard.path" - :active="dashboard.path === selectedDashboard.path" - active-class="is-active" - @click="selectDashboard(dashboard)" - > - {{ dashboard.display_name || dashboard.path }} - </gl-dropdown-item> - - <template v-if="isSystemDashboard"> + <gl-dropdown + toggle-class="dropdown-menu-toggle" + menu-class="monitor-dashboard-dropdown-menu" + :text="selectedDashboardText" + > + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-dashboard-dropdown-header text-center">{{ + __('Dashboard') + }}</gl-dropdown-header> <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorDashboardsDropdownSearch" + v-model="searchTerm" + class="m-2" + /> + <div class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="dashboard in filteredDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboard.path" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + {{ dashboard.display_name || dashboard.path }} + </gl-dropdown-item> + </div> - <gl-modal - ref="duplicateDashboardModal" - modal-id="duplicateDashboardModal" - :title="s__('Metrics|Duplicate dashboard')" - ok-variant="success" - @ok="ok" - @hide="hide" + <div + v-show="shouldShowNoMsgContainer" + ref="monitorDashboardsDropdownMsg" + class="text-secondary no-matches-message" > - <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> - {{ alert }} - </gl-alert> - <duplicate-dashboard-form - :dashboard="selectedDashboard" - :default-branch="defaultBranch" - @change="formChange" - /> - <template #modal-ok> - <gl-loading-icon v-if="loading" inline color="light" /> - {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }} - </template> - </gl-modal> + {{ __('No matching results') }} + </div> + + <template v-if="isSystemDashboard"> + <gl-dropdown-divider /> + + <gl-modal + ref="duplicateDashboardModal" + modal-id="duplicateDashboardModal" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ okButtonText }} + </template> + </gl-modal> - <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> - {{ s__('Metrics|Duplicate dashboard') }} - </gl-dropdown-item> - </template> + <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + {{ s__('Metrics|Duplicate dashboard') }} + </gl-dropdown-item> + </template> + </div> </gl-dropdown> </template> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue deleted file mode 100644 index 0aa710b1b3a..00000000000 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ /dev/null @@ -1,180 +0,0 @@ -<script> -import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; -import DateTimePickerInput from './date_time_picker_input.vue'; -import { - getTimeDiff, - isValidDate, - getTimeWindow, - stringToISODate, - ISODateToString, - truncateZerosInDateTime, - isDateTimePickerInputValid, -} from '~/monitoring/utils'; - -import { timeWindows } from '~/monitoring/constants'; - -const events = { - apply: 'apply', - invalid: 'invalid', -}; - -export default { - components: { - Icon, - DateTimePickerInput, - GlFormGroup, - GlButton, - GlDropdown, - GlDropdownItem, - }, - props: { - start: { - type: String, - required: true, - }, - end: { - type: String, - required: true, - }, - timeWindows: { - type: Object, - required: false, - default: () => timeWindows, - }, - }, - data() { - return { - startDate: this.start, - endDate: this.end, - }; - }, - computed: { - startInputValid() { - return isValidDate(this.startDate); - }, - endInputValid() { - return isValidDate(this.endDate); - }, - isValid() { - return this.startInputValid && this.endInputValid; - }, - - startInput: { - get() { - return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; - }, - set(val) { - // Attempt to set a formatted date if possible - this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; - }, - }, - endInput: { - get() { - return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; - }, - set(val) { - // Attempt to set a formatted date if possible - this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; - }, - }, - - timeWindowText() { - const timeWindow = getTimeWindow({ start: this.start, end: this.end }); - if (timeWindow) { - return this.timeWindows[timeWindow]; - } else if (isValidDate(this.start) && isValidDate(this.end)) { - return sprintf(s__('%{start} to %{end}'), { - start: this.formatDate(this.start), - end: this.formatDate(this.end), - }); - } - return ''; - }, - }, - mounted() { - // Validate on mounted, and trigger an update if needed - if (!this.isValid) { - this.$emit(events.invalid); - } - }, - methods: { - formatDate(date) { - return truncateZerosInDateTime(ISODateToString(date)); - }, - setTimeWindow(key) { - const { start, end } = getTimeDiff(key); - this.startDate = start; - this.endDate = end; - - this.apply(); - }, - closeDropdown() { - this.$refs.dropdown.hide(); - }, - apply() { - this.$emit(events.apply, { - start: this.startDate, - end: this.endDate, - }); - }, - }, -}; -</script> -<template> - <gl-dropdown - ref="dropdown" - :text="timeWindowText" - menu-class="time-window-dropdown-menu" - class="js-time-window-dropdown" - > - <div class="d-flex justify-content-between time-window-dropdown-menu-container"> - <gl-form-group - :label="__('Custom range')" - label-for="custom-from-time" - class="custom-time-range-form-group col-md-7 p-0 m-0" - > - <date-time-picker-input - id="custom-time-from" - v-model="startInput" - :label="__('From')" - :state="startInputValid" - /> - <date-time-picker-input - id="custom-time-to" - v-model="endInput" - :label="__('To')" - :state="endInputValid" - /> - <gl-form-group> - <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> - <gl-button variant="success" :disabled="!isValid" @click="apply()"> - {{ __('Apply') }} - </gl-button> - </gl-form-group> - </gl-form-group> - <gl-form-group - :label="__('Quick range')" - label-for="group-id-dropdown" - label-align="center" - class="col-md-4 p-0 m-0" - > - <gl-dropdown-item - v-for="(value, key) in timeWindows" - :key="key" - :active="value === timeWindowText" - active-class="active" - @click="setTimeWindow(key)" - > - <icon - name="mobile-issue-close" - class="align-bottom" - :class="{ invisible: value !== timeWindowText }" - /> - {{ value }} - </gl-dropdown-item> - </gl-form-group> - </div> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index 2f562071764..49188a7af8f 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -1,9 +1,9 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; -import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; -import { sidebarAnimationDuration } from '../constants'; -import { getTimeDiff } from '../utils'; +import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { timeRangeFromUrl, removeTimeRangeParams } from '../utils'; +import { sidebarAnimationDuration, defaultTimeRange } from '../constants'; let sidebarMutationObserver; @@ -18,17 +18,9 @@ export default { }, }, data() { - const defaultRange = getTimeDiff(); - const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start; - const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end; - - const params = { - start, - end, - }; - + const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange; return { - params, + timeRange: convertToFixedRange(timeRange), elWidth: 0, }; }, @@ -51,7 +43,9 @@ export default { }, mounted() { this.setInitialState(); - this.fetchMetricsData(this.params); + this.setTimeRange(this.timeRange); + this.fetchDashboard(); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver.observe(document.querySelector('.layout-page'), { attributes: true, @@ -66,7 +60,8 @@ export default { }, methods: { ...mapActions('monitoringDashboard', [ - 'fetchMetricsData', + 'setTimeRange', + 'fetchDashboard', 'setEndpoints', 'setFeatureFlags', 'setShowErrorBanner', @@ -81,7 +76,7 @@ export default { }, setInitialState() { this.setEndpoints({ - dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), + dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl), }); this.setShowErrorBanner(false); }, diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index ec6a41d0540..22fab1b03f2 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -1,6 +1,7 @@ <script> import { mapState } from 'vuex'; -import _ from 'underscore'; +import { pickBy } from 'lodash'; +import invalidUrl from '~/lib/utils/invalid_url'; import { GlDropdown, GlDropdownItem, @@ -14,14 +15,18 @@ import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorHeatmapChart from './charts/heatmap.vue'; +import MonitorColumnChart from './charts/column.vue'; +import MonitorStackedColumnChart from './charts/stacked_column.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { MonitorSingleStatChart, + MonitorColumnChart, MonitorHeatmapChart, + MonitorStackedColumnChart, MonitorEmptyChart, Icon, GlDropdown, @@ -54,8 +59,13 @@ export default { default: 'panel-type-chart', }, }, + data() { + return { + zoomedTimeRange: null, + }; + }, computed: { - ...mapState('monitoringDashboard', ['deploymentData', 'projectPath']), + ...mapState('monitoringDashboard', ['deploymentData', 'projectPath', 'logsPath', 'timeRange']), alertWidgetAvailable() { return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData; }, @@ -66,6 +76,14 @@ export default { this.graphData.metrics[0].result.length > 0 ); }, + logsPathWithTimeRange() { + const timeRange = this.zoomedTimeRange || this.timeRange; + + if (this.logsPath && this.logsPath !== invalidUrl && timeRange) { + return timeRangeToUrl(timeRange, this.logsPath); + } + return null; + }, csvText() { const chartData = this.graphData.metrics[0].result[0].values; const yLabel = this.graphData.y_label; @@ -90,7 +108,7 @@ export default { getGraphAlerts(queries) { if (!this.allAlerts) return {}; const metricIdsForChart = queries.map(q => q.metricId); - return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); + return pickBy(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); }, getGraphAlertValues(queries) { return Object.values(this.getGraphAlerts(queries)); @@ -103,6 +121,10 @@ export default { }, downloadCSVOptions, generateLinkToChartOptions, + + onDatazoom({ start, end }) { + this.zoomedTimeRange = { start, end }; + }, }, }; </script> @@ -114,16 +136,25 @@ export default { <monitor-heatmap-chart v-else-if="isPanelType('heatmap') && graphDataHasMetrics" :graph-data="graphData" - :container-width="dashboardWidth" + /> + <monitor-column-chart + v-else-if="isPanelType('column') && graphDataHasMetrics" + :graph-data="graphData" + /> + <monitor-stacked-column-chart + v-else-if="isPanelType('stacked-column') && graphDataHasMetrics" + :graph-data="graphData" /> <component :is="monitorChartComponent" v-else-if="graphDataHasMetrics" + ref="timeChart" :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" + @datazoom="onDatazoom" > <div class="d-flex align-items-center"> <alert-widget @@ -138,6 +169,7 @@ export default { v-gl-tooltip class="ml-auto mx-3" toggle-class="btn btn-transparent border-0" + data-qa-selector="prometheus_widgets_dropdown" :right="true" :no-caret="true" :title="__('More actions')" @@ -145,6 +177,15 @@ export default { <template slot="button-content"> <icon name="ellipsis_v" class="text-secondary" /> </template> + + <gl-dropdown-item + v-if="logsPathWithTimeRange" + ref="viewLogsLink" + :href="logsPathWithTimeRange" + > + {{ s__('Metrics|View logs') }} + </gl-dropdown-item> + <gl-dropdown-item v-track-event="downloadCSVOptions(graphData.title)" :href="downloadCsv" @@ -154,14 +195,18 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="clipboardText" + ref="copyChartLink" v-track-event="generateLinkToChartOptions(clipboardText)" - class="js-chart-link" :data-clipboard-text="clipboardText" @click="showToast(clipboardText)" > {{ __('Generate link to chart') }} </gl-dropdown-item> - <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}`"> + <gl-dropdown-item + v-if="alertWidgetAvailable" + v-gl-modal="`alert-modal-${index}`" + data-qa-selector="alert_widget_menu_item" + > {{ __('Alerts') }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 398b45b9012..ddf6c9878df 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -50,11 +50,6 @@ export const metricStates = { export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; -/** - * Valid strings for this regex are - * 2019-10-01 and 2019-10-01 01:02:03 - */ -export const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/; export const graphTypes = { deploymentData: 'scatter', @@ -75,6 +70,13 @@ export const colorValues = { anomalyAreaColor: '#1f78d1', }; +export const chartColorValues = [ + '#1f78d1', // $blue-500 (see variables.scss) + '#1aaa55', // $green-500 + '#fc9403', // $orange-500 + '#6d49cb', // $purple +]; + export const lineTypes = { default: 'solid', }; @@ -83,38 +85,41 @@ export const lineWidths = { default: 2, }; -export const timeWindows = { - thirtyMinutes: __('30 minutes'), - threeHours: __('3 hours'), - eightHours: __('8 hours'), - oneDay: __('1 day'), - threeDays: __('3 days'), - oneWeek: __('1 week'), -}; - export const dateFormats = { timeOfDay: 'h:MM TT', default: 'dd mmm yyyy, h:MMTT', - dateTimePicker: { - format: 'yyyy-mm-dd hh:mm:ss', - ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'", - stringDate: 'yyyy-mm-dd HH:MM:ss', - }, }; -export const secondsIn = { - thirtyMinutes: 60 * 30, - threeHours: 60 * 60 * 3, - eightHours: 60 * 60 * 8, - oneDay: 60 * 60 * 24 * 1, - threeDays: 60 * 60 * 24 * 3, - oneWeek: 60 * 60 * 24 * 7 * 1, -}; +export const timeRanges = [ + { + label: __('30 minutes'), + duration: { seconds: 60 * 30 }, + }, + { + label: __('3 hours'), + duration: { seconds: 60 * 60 * 3 }, + }, + { + label: __('8 hours'), + duration: { seconds: 60 * 60 * 8 }, + default: true, + }, + { + label: __('1 day'), + duration: { seconds: 60 * 60 * 24 * 1 }, + }, + { + label: __('3 days'), + duration: { seconds: 60 * 60 * 24 * 3 }, + }, + { + label: __('1 week'), + duration: { seconds: 60 * 60 * 24 * 7 * 1 }, + }, + { + label: __('1 month'), + duration: { seconds: 60 * 60 * 24 * 30 }, + }, +]; -export const timeWindowsKeyNames = Object.keys(secondsIn).reduce( - (otherTimeWindows, timeWindow) => ({ - ...otherTimeWindows, - [timeWindow]: timeWindow, - }), - {}, -); +export const defaultTimeRange = timeRanges.find(tr => tr.default); diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql new file mode 100644 index 00000000000..fd3a4348509 --- /dev/null +++ b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql @@ -0,0 +1,10 @@ +query getEnvironments($projectPath: ID!, $search: String) { + project(fullPath: $projectPath) { + data: environments(search: $search) { + environments: nodes { + name + id + } + } + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 61cd8621902..8bb5047ef04 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,9 +1,12 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; +import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; +import getEnvironments from '../queries/getEnvironments.query.graphql'; import statusCodes from '../../lib/utils/http_status'; -import { backOff } from '../../lib/utils/common_utils'; +import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { PROMETHEUS_TIMEOUT } from '../constants'; @@ -30,6 +33,15 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; +export const setTimeRange = ({ commit }, timeRange) => { + commit(types.SET_TIME_RANGE, timeRange); +}; + +export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { + commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); + dispatch('fetchEnvironmentsData'); +}; + export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; @@ -40,6 +52,8 @@ export const requestMetricsDashboard = ({ commit }) => { export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard); + commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(response.metrics_data)); + return dispatch('fetchPrometheusMetrics', params); }; export const receiveMetricsDashboardFailure = ({ commit }, error) => { @@ -50,24 +64,30 @@ export const receiveDeploymentsDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); export const receiveDeploymentsDataFailure = ({ commit }) => commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); +export const requestEnvironmentsData = ({ commit }) => commit(types.REQUEST_ENVIRONMENTS_DATA); export const receiveEnvironmentsDataSuccess = ({ commit }, data) => commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); export const receiveEnvironmentsDataFailure = ({ commit }) => commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); -export const fetchData = ({ dispatch }, params) => { - dispatch('fetchMetricsData', params); +export const fetchData = ({ dispatch }) => { + dispatch('fetchDashboard'); dispatch('fetchDeploymentsData'); dispatch('fetchEnvironmentsData'); }; -export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params); - -export const fetchDashboard = ({ state, dispatch }, params) => { +export const fetchDashboard = ({ state, dispatch }) => { dispatch('requestMetricsDashboard'); + const params = {}; + + if (state.timeRange) { + const { start, end } = convertToFixedRange(state.timeRange); + params.start = start; + params.end = end; + } + if (state.currentDashboard) { - // eslint-disable-next-line no-param-reassign params.dashboard = state.currentDashboard; } @@ -184,19 +204,26 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { }; export const fetchEnvironmentsData = ({ state, dispatch }) => { - if (!state.environmentsEndpoint) { - return Promise.resolve([]); - } - return axios - .get(state.environmentsEndpoint) - .then(resp => resp.data) - .then(response => { - if (!response || !response.environments) { + dispatch('requestEnvironmentsData'); + return gqClient + .mutate({ + mutation: getEnvironments, + variables: { + projectPath: removeLeadingSlash(state.projectPath), + search: state.environmentsSearchTerm, + }, + }) + .then(resp => + parseEnvironmentsResponse(resp.data?.project?.data?.environments, state.projectPath), + ) + .then(environments => { + if (!environments) { createFlash( s__('Metrics|There was an error fetching the environments data, please try again'), ); } - dispatch('receiveEnvironmentsDataSuccess', response.environments); + + dispatch('receiveEnvironmentsDataSuccess', environments); }) .catch(() => { dispatch('receiveEnvironmentsDataFailure'); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index a13157c6f87..3801149e49d 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -58,5 +58,18 @@ export const metricsWithData = state => groupKey => { return res; }; +/** + * Filter environments by names. + * + * This is used in the environments dropdown with searchable input. + * + * @param {Object} state + * @returns {Array} List of environments + */ +export const filteredEnvironments = state => + state.environments.filter(env => + env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()), + ); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 74068e1d846..8873142accc 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -14,10 +14,12 @@ export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT'; export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS'; export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE'; -export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; +export const SET_TIME_RANGE = 'SET_TIME_RANGE'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; + +export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 506a30ae619..8bd53a24b61 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import pick from 'lodash/pick'; import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; import { normalizeMetric, normalizeQueryResult } from './utils'; @@ -123,10 +124,15 @@ export default { [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) { state.deploymentData = []; }, + [types.REQUEST_ENVIRONMENTS_DATA](state) { + state.environmentsLoading = true; + }, [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) { + state.environmentsLoading = false; state.environments = environments; }, [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { + state.environmentsLoading = false; state.environments = []; }, @@ -169,15 +175,22 @@ export default { state: emptyStateFromError(error), }); }, - - [types.SET_ENDPOINTS](state, endpoints) { - state.metricsEndpoint = endpoints.metricsEndpoint; - state.environmentsEndpoint = endpoints.environmentsEndpoint; - state.deploymentsEndpoint = endpoints.deploymentsEndpoint; - state.dashboardEndpoint = endpoints.dashboardEndpoint; - state.dashboardsEndpoint = endpoints.dashboardsEndpoint; - state.currentDashboard = endpoints.currentDashboard; - state.projectPath = endpoints.projectPath; + [types.SET_ENDPOINTS](state, endpoints = {}) { + const endpointKeys = [ + 'metricsEndpoint', + 'deploymentsEndpoint', + 'dashboardEndpoint', + 'dashboardsEndpoint', + 'currentDashboard', + 'projectPath', + 'logsPath', + ]; + Object.entries(pick(endpoints, endpointKeys)).forEach(([key, value]) => { + state[key] = value; + }); + }, + [types.SET_TIME_RANGE](state, timeRange) { + state.timeRange = timeRange; }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; @@ -196,4 +209,7 @@ export default { const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); panelGroup.panels = payload.panels; }, + [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) { + state.environmentsSearchTerm = searchTerm; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index ee8a85ea222..a2050f8e893 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,21 +1,31 @@ import invalidUrl from '~/lib/utils/invalid_url'; export default () => ({ + // API endpoints metricsEndpoint: null, - environmentsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, + + // Dashboard request parameters + timeRange: null, + currentDashboard: null, + + // Dashboard data emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, - dashboard: { panel_groups: [], }, + allDashboards: [], + // Other project data deploymentData: [], environments: [], - allDashboards: [], - currentDashboard: null, + environmentsSearchTerm: '', + environmentsLoading: false, + + // GitLab paths to other pages projectPath: null, + logsPath: invalidUrl, }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 3300d2032d0..cd586c6af3e 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -1,8 +1,46 @@ -import _ from 'underscore'; +import { omit } from 'lodash'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export const gqClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`; /** + * Project path has a leading slash that doesn't work well + * with project full path resolver here + * https://gitlab.com/gitlab-org/gitlab/blob/5cad4bd721ab91305af4505b2abc92b36a56ad6b/app/graphql/resolvers/full_path_resolver.rb#L10 + * + * @param {String} str String with leading slash + * @returns {String} + */ +export const removeLeadingSlash = str => (str || '').replace(/^\/+/, ''); + +/** + * GraphQL environments API returns only id and name. + * For the environments dropdown we need metrics_path. + * This method parses the results and add neccessart attrs + * + * @param {Array} response Environments API result + * @param {String} projectPath Current project path + * @returns {Array} + */ +export const parseEnvironmentsResponse = (response = [], projectPath) => + (response || []).map(env => { + const id = getIdFromGraphQLId(env.id); + return { + ...env, + id, + metrics_path: `${projectPath}/environments/${id}/metrics`, + }; + }); + +/** * Metrics loaded from project-defined dashboards do not have a metric_id. * This method creates a unique ID combining metric_id and id, if either is present. * This is hopefully a temporary solution until BE processes metrics before passing to fE @@ -11,7 +49,7 @@ export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`; */ export const normalizeMetric = (metric = {}) => - _.omit( + omit( { ...metric, metric_id: uniqMetricsId(metric), diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index c824d6d4ddb..b2fa44835e6 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,67 +1,9 @@ -import dateformat from 'dateformat'; -import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; -import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; - -export const getTimeDiff = timeWindow => { - const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds - const difference = secondsIn[timeWindow] || secondsIn.eightHours; - const start = end - difference; - - return { - start: new Date(secondsToMilliseconds(start)).toISOString(), - end: new Date(secondsToMilliseconds(end)).toISOString(), - }; -}; - -export const getTimeWindow = ({ start, end }) => - Object.entries(secondsIn).reduce((acc, [timeRange, value]) => { - if (new Date(end) - new Date(start) === secondsToMilliseconds(value)) { - return timeRange; - } - return acc; - }, null); - -export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val); - -export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', ''); - -/** - * The URL params start and end need to be validated - * before passing them down to other components. - * - * @param {string} dateString - */ -export const isValidDate = dateString => { - try { - // dateformat throws error that can be caught. - // This is better than using `new Date()` - if (dateString && dateString.trim()) { - dateformat(dateString, 'isoDateTime'); - return true; - } - return false; - } catch (e) { - return false; - } -}; - -/** - * Convert the input in Time picker component to ISO date. - * - * @param {string} val - * @returns {string} - */ -export const stringToISODate = val => - dateformat(new Date(val.replace(/-/g, '/')), dateFormats.dateTimePicker.ISODate, true); - -/** - * Convert the ISO date received from the URL to string - * for the Time picker component. - * - * @param {Date} date - * @returns {string} - */ -export const ISODateToString = date => dateformat(date, dateFormats.dateTimePicker.stringDate); +import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; +import { + timeRangeParamNames, + timeRangeFromParams, + timeRangeToParams, +} from '~/lib/utils/datetime_range'; /** * This method is used to validate if the graph data format for a chart component @@ -158,4 +100,36 @@ export const graphDataValidatorForAnomalyValues = graphData => { ); }; +/** + * Returns a time range from the current URL params + * + * @returns {Object|null} The time range defined by the + * current URL, reading from search query or `window.location.search`. + * Returns `null` if no parameters form a time range. + */ +export const timeRangeFromUrl = (search = window.location.search) => { + const params = queryToObject(search); + return timeRangeFromParams(params); +}; + +/** + * Returns a URL with no time range based on the current URL. + * + * @param {String} New URL + */ +export const removeTimeRangeParams = (url = window.location.href) => + removeParams(timeRangeParamNames, url); + +/** + * Returns a URL for the a different time range based on the + * current URL and a time range. + * + * @param {String} New URL + */ +export const timeRangeToUrl = (timeRange, url = window.location.href) => { + const toUrl = removeTimeRangeParams(url); + const params = timeRangeToParams(timeRange); + return mergeUrlParams(params, toUrl); +}; + export default {}; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 622db360d1f..2580f8e86b1 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -4,6 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import store from 'ee_else_ce/mr_notes/stores'; import notesApp from '../notes/components/notes_app.vue'; import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue'; +import initWidget from '../vue_merge_request_widget'; export default () => { // eslint-disable-next-line no-new @@ -32,11 +33,22 @@ export default () => { ...mapState({ activeTab: state => state.page.activeTab, }), + isShowTabActive() { + return this.activeTab === 'show'; + }, }, watch: { discussionTabCounter() { this.updateDiscussionTabCounter(); }, + isShowTabActive: { + handler(newVal) { + if (newVal) { + initWidget(); + } + }, + immediate: true, + }, }, created() { this.setActiveTab(window.mrTabs.getCurrentAction()); @@ -57,19 +69,17 @@ export default () => { }, }, render(createElement) { - const isDiffView = this.activeTab === 'diffs'; - // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`, // it adds a global key listener so it works on the diffs tab as well. // If we create a single Vue app for all of the MR tabs, we should move this // up the tree, to the root. - return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [ + return createElement(discussionKeyboardNavigator, [ createElement('notes-app', { props: { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, - shouldShow: this.activeTab === 'show', + shouldShow: this.isShowTabActive, helpPagePath: this.helpPagePath, }, }), diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js index c13c417cc18..352bc635293 100644 --- a/app/assets/javascripts/mr_popover/constants.js +++ b/app/assets/javascripts/mr_popover/constants.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; export const mrStates = { merged: 'merged', closed: 'closed', + open: 'open', }; export const humanMRStates = { diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index c301c304409..3cc95168ba1 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, camelcase */ +/* eslint-disable func-names, consistent-return */ import $ from 'jquery'; import { __ } from '../locale'; @@ -270,14 +270,14 @@ export default class BranchGraph { stroke: 'none', }); - const avatar_box_x = this.offsetX + this.unitSpace * this.mspace + 10; - const avatar_box_y = y - 10; + const avatarBoxX = this.offsetX + this.unitSpace * this.mspace + 10; + const avatarBoxY = y - 10; - r.rect(avatar_box_x, avatar_box_y, 20, 20).attr({ + r.rect(avatarBoxX, avatarBoxY, 20, 20).attr({ stroke: this.colors[commit.space], 'stroke-width': 2, }); - r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20); + r.image(commit.author.icon, avatarBoxX, avatarBoxY, 20, 20); return r .text(this.offsetX + this.unitSpace * this.mspace + 35, y, commit.message.split('\n')[0]) .attr({ diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 4195ea6425f..b3b189c1114 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,4 +1,4 @@ -/* eslint-disable no-restricted-properties, camelcase, +/* eslint-disable no-restricted-properties, babel/camelcase, no-unused-expressions, default-case, consistent-return, no-alert, no-param-reassign, no-else-return, no-shadow, no-useless-escape, @@ -11,11 +11,11 @@ old_notes_spec.js is the spec for the legacy, jQuery notes application. It has n */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape, uniqueId } from 'lodash'; import Cookies from 'js-cookie'; import Autosize from 'autosize'; import 'jquery.caret'; // required by at.js -import 'at.js'; +import '@gitlab/at.js'; import Vue from 'vue'; import { GlSkeletonLoading } from '@gitlab/ui'; import AjaxCache from '~/lib/utils/ajax_cache'; @@ -1449,7 +1449,7 @@ export default class Notes { return { // eslint-disable-next-line no-jquery/no-serialize formData: $form.serialize(), - formContent: _.escape(content), + formContent: escape(content), formAction: $form.attr('action'), formContentOriginal: content, }; @@ -1516,18 +1516,16 @@ export default class Notes { `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> - <a href="/${_.escape(currentUsername)}"> + <a href="/${escape(currentUsername)}"> <img class="avatar s40" src="${currentUserAvatar}" /> </a> </div> <div class="timeline-content ${discussionClass}"> <div class="note-header"> <div class="note-header-info"> - <a href="/${_.escape(currentUsername)}"> - <span class="d-none d-sm-inline-block bold">${_.escape( - currentUsername, - )}</span> - <span class="note-headline-light">${_.escape(currentUsername)}</span> + <a href="/${escape(currentUsername)}"> + <span class="d-none d-sm-inline-block bold">${escape(currentUsername)}</span> + <span class="note-headline-light">${escape(currentUsername)}</span> </a> </div> </div> @@ -1541,8 +1539,8 @@ export default class Notes { </li>`, ); - $tempNote.find('.d-none.d-sm-inline-block').text(_.escape(currentUserFullname)); - $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`); + $tempNote.find('.d-none.d-sm-inline-block').text(escape(currentUserFullname)); + $tempNote.find('.note-headline-light').text(`@${escape(currentUsername)}`); return $tempNote; } @@ -1627,7 +1625,7 @@ export default class Notes { // Show placeholder note if (tempFormContent) { - noteUniqueId = _.uniqueId('tempNote_'); + noteUniqueId = uniqueId('tempNote_'); $notesContainer.append( this.createPlaceholderNote({ formContent: tempFormContent, @@ -1642,7 +1640,7 @@ export default class Notes { // Show placeholder system note if (hasQuickActions) { - systemNoteUniqueId = _.uniqueId('tempSystemNote_'); + systemNoteUniqueId = uniqueId('tempSystemNote_'); $notesContainer.append( this.createPlaceholderSystemNote({ formContent: this.getQuickActionDescription( @@ -1825,7 +1823,7 @@ export default class Notes { }) .catch(() => { // Submission failed, revert back to original note - $noteBodyText.html(_.escape(cachedNoteBodyText)); + $noteBodyText.html(escape(cachedNoteBodyText)); $editingNote.removeClass('being-posted fade-in'); $editingNote.find('.fa.fa-spinner').remove(); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4ca32b9b005..9a809b71a58 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import Autosize from 'autosize'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -161,7 +161,7 @@ export default { 'toggleStateButtonLoading', ]), setIsSubmitButtonDisabled(note, isSubmitting) { - if (!_.isEmpty(note) && !isSubmitting) { + if (!isEmpty(note) && !isSubmitting) { this.isSubmitButtonDisabled = false; } else { this.isSubmitButtonDisabled = true; diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 4c9075912ee..50d224a2f08 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import _ from 'underscore'; +import { escape } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -45,7 +45,7 @@ export default { return this.notes.length > 1 ? this.lastNote.created_at : null; }, headerText() { - const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkStart = `<a href="${escape(this.discussion.discussion_path)}">`; const linkEnd = '</a>'; const { commit_id: commitId } = this.discussion; diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index fad1bc67be7..8ab31ef3448 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -73,7 +73,7 @@ export default { v-if="discussion.resolvable && shouldShowJumpToNextDiscussion" class="btn-group discussion-actions ml-sm-2" > - <jump-to-next-discussion-button @onClick="$emit('jumpToNextDiscussion')" /> + <jump-to-next-discussion-button /> </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 98f1f385e9b..70e22db364b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import discussionNavigation from '../mixins/discussion_navigation'; @@ -17,9 +17,7 @@ export default { 'getUserData', 'getNoteableData', 'resolvableDiscussionsCount', - 'firstUnresolvedDiscussionId', 'unresolvedDiscussionsCount', - 'getDiscussion', ]), isLoggedIn() { return this.getUserData.id; @@ -37,16 +35,6 @@ export default { return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; }, }, - methods: { - ...mapActions(['expandDiscussion']), - jumpToFirstUnresolvedDiscussion() { - const diffTab = window.mrTabs.currentAction === 'diffs'; - const discussionId = - this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId(); - const firstDiscussion = this.getDiscussion(discussionId); - this.jumpToDiscussion(firstDiscussion); - }, - }, }; </script> @@ -83,9 +71,9 @@ export default { <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> <button v-gl-tooltip - title="Jump to first unresolved thread" + title="Jump to next unresolved thread" class="btn btn-default discussion-next-btn" - @click="jumpToFirstUnresolvedDiscussion" + @click="jumpToNextDiscussion" > <icon name="comment-next" /> </button> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 889731df180..8dc4b43d69a 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -38,12 +38,12 @@ export default { <icon name="comment" /> </div> <div class="timeline-content"> - <div v-html="timelineContent"></div> + <div ref="timelineContent" v-html="timelineContent"></div> <div class="discussion-filter-actions mt-2"> - <gl-button variant="default" @click="selectFilter(0)"> + <gl-button ref="showAllActivity" variant="default" @click="selectFilter(0)"> {{ __('Show all activity') }} </gl-button> - <gl-button variant="default" @click="selectFilter(1)"> + <gl-button ref="showComments" variant="default" @click="selectFilter(1)"> {{ __('Show comments only') }} </gl-button> </div> diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue index f87ca097b40..630d4fd89b1 100644 --- a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue +++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue @@ -1,6 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import icon from '~/vue_shared/components/icon.vue'; +import discussionNavigation from '../mixins/discussion_navigation'; export default { name: 'JumpToNextDiscussionButton', @@ -10,6 +11,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [discussionNavigation], }; </script> @@ -19,8 +21,8 @@ export default { ref="button" v-gl-tooltip class="btn btn-default discussion-next-btn" - :title="s__('MergeRequests|Jump to next unresolved discussion')" - @click="$emit('onClick')" + :title="s__('MergeRequests|Jump to next unresolved thread')" + @click="jumpToNextDiscussion" > <icon name="comment-next" /> </button> diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue index 7d742fbfeee..2dc222d08f9 100644 --- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue @@ -1,53 +1,18 @@ <script> /* global Mousetrap */ import 'mousetrap'; -import { mapGetters, mapActions } from 'vuex'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; export default { mixins: [discussionNavigation], - props: { - isDiffView: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - currentDiscussionId: null, - }; - }, - computed: { - ...mapGetters([ - 'nextUnresolvedDiscussionId', - 'previousUnresolvedDiscussionId', - 'getDiscussion', - ]), - }, mounted() { - Mousetrap.bind('n', () => this.jumpToNextDiscussion()); - Mousetrap.bind('p', () => this.jumpToPreviousDiscussion()); + Mousetrap.bind('n', this.jumpToNextDiscussion); + Mousetrap.bind('p', this.jumpToPreviousDiscussion); }, beforeDestroy() { Mousetrap.unbind('n'); Mousetrap.unbind('p'); }, - methods: { - ...mapActions(['expandDiscussion']), - jumpToNextDiscussion() { - const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - const nextDiscussion = this.getDiscussion(nextId); - this.jumpToDiscussion(nextDiscussion); - this.currentDiscussionId = nextId; - }, - jumpToPreviousDiscussion() { - const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - const prevDiscussion = this.getDiscussion(prevId); - this.jumpToDiscussion(prevDiscussion); - this.currentDiscussionId = prevId; - }, - }, render() { return this.$slots.default; }, diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index b6d8c831e2e..72f9a4c7e74 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -12,11 +12,23 @@ export default { <template> <div class="note-attachment"> - <a v-if="attachment.image" :href="attachment.url" target="_blank" rel="noopener noreferrer"> + <a + v-if="attachment.image" + ref="attachmentImage" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer" + > <img :src="attachment.url" class="note-image-attach" /> </a> <div class="attachment"> - <a v-if="attachment.url" :href="attachment.url" target="_blank" rel="noopener noreferrer"> + <a + v-if="attachment.url" + ref="attachmentUrl" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer" + > <i class="fa fa-paperclip" aria-hidden="true"> </i> {{ attachment.filename }} </a> </div> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index e4f09492d9c..16351baedb7 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -63,13 +63,13 @@ export default { <template> <div class="note-header-info"> - <div v-if="includeToggle" class="discussion-actions"> + <div v-if="includeToggle" ref="discussionActions" class="discussion-actions"> <button class="note-action-button discussion-toggle-button js-vue-toggle-button" type="button" @click="handleToggle" > - <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i> + <i ref="chevronIcon" :class="toggleChevronClass" class="fa" aria-hidden="true"></i> {{ __('Toggle thread') }} </button> </div> @@ -90,10 +90,11 @@ export default { <span class="note-headline-light note-headline-meta"> <span class="system-note-message"> <slot></slot> </span> <template v-if="createdAt"> - <span class="system-note-separator"> + <span ref="actionText" class="system-note-separator"> <template v-if="actionText">{{ actionText }}</template> </span> <a + ref="noteTimestamp" :href="noteTimestampLink" class="note-timestamp system-note-separator" @click="updateTargetNoteHash" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 3462ee72dd3..189ff88feb3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -14,7 +14,6 @@ import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; -import discussionNavigation from '../mixins/discussion_navigation'; import eventHub from '../event_hub'; import DiscussionNotes from './discussion_notes.vue'; import DiscussionActions from './discussion_actions.vue'; @@ -35,7 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin], + mixins: [noteable, resolvable, diffLineNoteFormMixin], props: { discussion: { type: Object, @@ -79,12 +78,8 @@ export default { 'convertedDisscussionIds', 'getNoteableData', 'userCanReply', - 'nextUnresolvedDiscussionId', - 'unresolvedDiscussionsCount', - 'hasUnresolvedDiscussions', 'showJumpToNextDiscussion', 'getUserData', - 'getDiscussion', ]), currentUser() { return this.getUserData; @@ -152,7 +147,6 @@ export default { 'saveNote', 'removePlaceholderNotes', 'toggleResolveNote', - 'expandDiscussion', 'removeConvertedDiscussion', ]), showReplyForm() { @@ -219,15 +213,6 @@ export default { callback(err); }); }, - jumpToNextDiscussion() { - const nextId = this.nextUnresolvedDiscussionId( - this.discussion.id, - this.discussionsByDiffOrder, - ); - const nextDiscussion = this.getDiscussion(nextId); - - this.jumpToDiscussion(nextDiscussion); - }, deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); }, @@ -294,7 +279,6 @@ export default { :should-show-jump-to-next-discussion="shouldShowJumpToNextDiscussion" @showReplyForm="showReplyForm" @resolve="resolveHandler" - @jumpToNextDiscussion="jumpToNextDiscussion" /> <div v-if="isReplying" class="avatar-note-form-holder"> <user-avatar-link diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index b3dae69d0bc..dea782683f2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; -import { escape } from 'underscore'; +import { escape } from 'lodash'; import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index be2adb07526..762228dd138 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -14,7 +14,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { __ } from '~/locale'; -import initUserPopovers from '../../user_popovers'; +import initUserPopovers from '~/user_popovers'; export default { name: 'NotesApp', diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index f1b0b12bdce..dd132d4f608 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { uniqBy } from 'lodash'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -27,7 +27,7 @@ export default { uniqueAuthors() { const authors = this.replies.map(reply => reply.author || {}); - return _.uniq(authors, author => author.username); + return uniqBy(authors, author => author.username); }, className() { return this.collapsed ? 'collapsed' : 'expanded'; diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 68c117183a1..e9a81bc9553 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -18,6 +18,7 @@ export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; export const DISCUSSION_TAB_LABEL = 'show'; export const NOTE_UNDERSCORE = 'note_'; +export const TIME_DIFFERENCE_VALUE = 10; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js index 12d80f3faa2..66e6685cfd8 100644 --- a/app/assets/javascripts/notes/mixins/description_version_history.js +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -3,10 +3,12 @@ export default { computed: { canSeeDescriptionVersion() {}, + canDeleteDescriptionVersion() {}, shouldShowDescriptionVersion() {}, descriptionVersionToggleIcon() {}, }, methods: { toggleDescriptionVersion() {}, + deleteDescriptionVersion() {}, }, }; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 94ca01e44cc..e5066695403 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,8 +1,21 @@ +import { mapGetters, mapActions, mapState } from 'vuex'; import { scrollToElement } from '~/lib/utils/common_utils'; import eventHub from '../../notes/event_hub'; export default { + computed: { + ...mapGetters([ + 'nextUnresolvedDiscussionId', + 'previousUnresolvedDiscussionId', + 'getDiscussion', + ]), + ...mapState({ + currentDiscussionId: state => state.notes.currentDiscussionId, + }), + }, methods: { + ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), + diffsJump(id) { const selector = `ul.notes[data-discussion-id="${id}"]`; @@ -58,5 +71,21 @@ export default { } } }, + + jumpToNextDiscussion() { + this.handleDiscussionJump(this.nextUnresolvedDiscussionId); + }, + + jumpToPreviousDiscussion() { + this.handleDiscussionJump(this.previousUnresolvedDiscussionId); + }, + + handleDiscussionJump(fn) { + const isDiffView = window.mrTabs.currentAction === 'diffs'; + const targetId = fn(this.currentDiscussionId, isDiffView); + const discussion = this.getDiscussion(targetId); + this.jumpToDiscussion(discussion); + this.setCurrentDiscussionId(targetId); + }, }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 9bd245c094d..594e3a14d56 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -491,20 +491,66 @@ export const convertToDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) => commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); -export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => { +export const setCurrentDiscussionId = ({ commit }, discussionId) => + commit(types.SET_CURRENT_DISCUSSION_ID, discussionId); + +export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersion }) => { let requestUrl = endpoint; if (startingVersion) { requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); } + dispatch('requestDescriptionVersion'); return axios .get(requestUrl) - .then(res => res.data) - .catch(() => { + .then(res => { + dispatch('receiveDescriptionVersion', res.data); + }) + .catch(error => { + dispatch('receiveDescriptionVersionError', error); Flash(__('Something went wrong while fetching description changes. Please try again.')); }); }; +export const requestDescriptionVersion = ({ commit }) => { + commit(types.REQUEST_DESCRIPTION_VERSION); +}; +export const receiveDescriptionVersion = ({ commit }, descriptionVersion) => { + commit(types.RECEIVE_DESCRIPTION_VERSION, descriptionVersion); +}; +export const receiveDescriptionVersionError = ({ commit }, error) => { + commit(types.RECEIVE_DESCRIPTION_VERSION_ERROR, error); +}; + +export const softDeleteDescriptionVersion = ({ dispatch }, { endpoint, startingVersion }) => { + let requestUrl = endpoint; + + if (startingVersion) { + requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); + } + dispatch('requestDeleteDescriptionVersion'); + + return axios + .delete(requestUrl) + .then(() => { + dispatch('receiveDeleteDescriptionVersion'); + }) + .catch(error => { + dispatch('receiveDeleteDescriptionVersionError', error); + Flash(__('Something went wrong while deleting description changes. Please try again.')); + }); +}; + +export const requestDeleteDescriptionVersion = ({ commit }) => { + commit(types.REQUEST_DELETE_DESCRIPTION_VERSION); +}; +export const receiveDeleteDescriptionVersion = ({ commit }) => { + commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION, __('Deleted')); +}; +export const receiveDeleteDescriptionVersionError = ({ commit }, error) => { + commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR, error); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index 3cdcc7a05b8..d94fc626a3f 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -1,4 +1,4 @@ -import { DESCRIPTION_TYPE } from '../constants'; +import { DESCRIPTION_TYPE, TIME_DIFFERENCE_VALUE } from '../constants'; /** * Checks the time difference between two notes from their 'created_at' dates @@ -45,7 +45,11 @@ export const collapseSystemNotes = notes => { const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); // are they less than 10 minutes apart from the same user? - if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) { + if ( + timeDifferenceMinutes > TIME_DIFFERENCE_VALUE || + note.author.id !== lastDescriptionSystemNote.author.id || + lastDescriptionSystemNote.description_version_deleted + ) { // update the previous system note lastDescriptionSystemNote = note; lastDescriptionSystemNoteIndex = acc.length; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 3d0ec8cd3a7..4f8ff8240b2 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { flattenDeep } from 'lodash'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -50,7 +50,7 @@ const isLastNote = (note, state) => !note.system && state.userData && note.author && note.author.id === state.userData.id; export const getCurrentUserLastNote = state => - _.flatten(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el => + flattenDeep(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el => isLastNote(el, state), ); @@ -59,7 +59,6 @@ export const getDiscussionLastNote = state => discussion => export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount; export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; -export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; export const showJumpToNextDiscussion = (state, getters) => (mode = 'discussion') => { const orderedDiffs = diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 6168aeae35d..0e991f2f4f0 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -8,11 +8,13 @@ export default () => ({ convertedDisscussionIds: [], targetNoteHash: null, lastFetchedAt: null, + currentDiscussionId: null, // View layer isToggleStateButtonLoading: false, isNotesFetched: false, isLoading: true, + isLoadingDescriptionVersion: false, // holds endpoints and permissions provided through haml notesData: { @@ -26,7 +28,7 @@ export default () => ({ commentsDisabled: false, resolvableDiscussionsCount: 0, unresolvedDiscussionsCount: 0, - hasUnresolvedDiscussions: false, + descriptionVersion: null, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 796370920bb..6554aee0d5b 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -25,8 +25,17 @@ export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; +export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; + +// Description version +export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; +export const RECEIVE_DESCRIPTION_VERSION = 'RECEIVE_DESCRIPTION_VERSION'; +export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ERROR'; +export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION'; +export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION'; +export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index e70f0238316..d32a88e4c71 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -267,7 +267,6 @@ export default { discussion.resolvable && discussion.notes.some(note => note.resolvable && !note.resolved), ).length; - state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; }, [types.CONVERT_TO_DISCUSSION](state, discussionId) { @@ -281,4 +280,29 @@ export default { convertedDisscussionIds.splice(convertedDisscussionIds.indexOf(discussionId), 1); Object.assign(state, { convertedDisscussionIds }); }, + + [types.SET_CURRENT_DISCUSSION_ID](state, discussionId) { + state.currentDiscussionId = discussionId; + }, + + [types.REQUEST_DESCRIPTION_VERSION](state) { + state.isLoadingDescriptionVersion = true; + }, + [types.RECEIVE_DESCRIPTION_VERSION](state, descriptionVersion) { + state.isLoadingDescriptionVersion = false; + state.descriptionVersion = descriptionVersion; + }, + [types.RECEIVE_DESCRIPTION_VERSION_ERROR](state) { + state.isLoadingDescriptionVersion = false; + }, + [types.REQUEST_DELETE_DESCRIPTION_VERSION](state) { + state.isLoadingDescriptionVersion = true; + }, + [types.RECEIVE_DELETE_DESCRIPTION_VERSION](state, descriptionVersion) { + state.isLoadingDescriptionVersion = false; + state.descriptionVersion = descriptionVersion; + }, + [types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](state) { + state.isLoadingDescriptionVersion = false; + }, }; diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index dcd226795a6..fa27994f598 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -26,7 +26,7 @@ export default class NotificationsForm { .addClass('is-loading') .find('.custom-notification-event-loading') .removeClass('fa-check') - .addClass('fa-spin fa-spinner') + .addClass('spinner align-middle') .removeClass('is-done'); } @@ -41,12 +41,12 @@ export default class NotificationsForm { if (data.saved) { $parent .find('.custom-notification-event-loading') - .toggleClass('fa-spin fa-spinner fa-check is-done'); + .toggleClass('spinner fa-check is-done align-middle'); setTimeout(() => { $parent .removeClass('is-loading') .find('.custom-notification-event-loading') - .toggleClass('fa-spin fa-spinner fa-check is-done'); + .toggleClass('spinner fa-check is-done align-middle'); }, 2000); } }) diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index 089dedd14cb..78b7e29ae53 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -3,9 +3,7 @@ import projectSelect from '~/project_select'; import selfMonitor from '~/self_monitor'; document.addEventListener('DOMContentLoaded', () => { - if (gon.features && gon.features.selfMonitoringProject) { - selfMonitor(); - } + selfMonitor(); // Initialize expandable settings panels initSettingsPanels(); projectSelect(); diff --git a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js b/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js index 9a1bc46bf4a..95f4ba28b42 100644 --- a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js +++ b/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js @@ -26,18 +26,18 @@ export default class UsagePingPayload { requestPayload() { if (this.isInserted) return this.showPayload(); - this.spinner.classList.add('d-inline'); + this.spinner.classList.add('d-inline-flex'); return axios .get(this.container.dataset.endpoint, { responseType: 'text', }) .then(({ data }) => { - this.spinner.classList.remove('d-inline'); + this.spinner.classList.remove('d-inline-flex'); this.insertPayload(data); }) .catch(() => { - this.spinner.classList.remove('d-inline'); + this.spinner.classList.remove('d-inline-flex'); flash(__('Error fetching usage ping data.')); }); } diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js new file mode 100644 index 00000000000..5be466886a5 --- /dev/null +++ b/app/assets/javascripts/pages/admin/serverless/domains/index.js @@ -0,0 +1,19 @@ +import initSettingsPanels from '~/settings_panels'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); + + const domainCard = document.querySelector('.js-domain-cert-show'); + const domainForm = document.querySelector('.js-domain-cert-inputs'); + const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); + const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); + + if (domainReplaceButton && domainCard && domainForm) { + domainReplaceButton.addEventListener('click', () => { + domainCard.classList.add('hidden'); + domainForm.classList.remove('hidden'); + domainSubmitButton.removeAttribute('disabled'); + }); + } +}); diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js index 635513afd95..47fea2be189 100644 --- a/app/assets/javascripts/pages/groups/registry/repositories/index.js +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -1,3 +1,9 @@ -import initRegistryImages from '~/registry/list'; +import initRegistryImages from '~/registry/list/index'; +import registryExplorer from '~/registry/explorer/index'; -document.addEventListener('DOMContentLoaded', initRegistryImages); +document.addEventListener('DOMContentLoaded', () => { + initRegistryImages(); + const { attachMainComponent, attachBreadcrumb } = registryExplorer(); + attachBreadcrumb(); + attachMainComponent(); +}); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index aee67899ca2..caf9a8c0b64 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => { } GpgBadges.fetch(); + + if (gon.features?.codeNavigation) { + // eslint-disable-next-line promise/catch-or-return + import('~/code_navigation').then(m => m.default()); + } }); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 314519ee442..803f4e37705 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -1,49 +1,15 @@ -import $ from 'jquery'; -import Chart from 'chart.js'; -import _ from 'underscore'; - -import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils'; +import Vue from 'vue'; +import { __ } from '~/locale'; +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import SeriesDataMixin from './series_data_mixin'; document.addEventListener('DOMContentLoaded', () => { - const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML); - - const barChart = (selector, data) => { - // get selector by context - const ctx = selector.get(0).getContext('2d'); - // pointing parent container to make chart.js inherit its width - const container = $(selector).parent(); - selector.attr('width', $(container).width()); - - // Scale fonts if window width lower than 768px (iPad portrait) - const shouldAdjustFontSize = window.innerWidth < 768; - return new Chart(ctx, { - type: 'bar', - data, - options: barChartOptions(shouldAdjustFontSize), - }); - }; + const languagesContainer = document.getElementById('js-languages-chart'); + const monthContainer = document.getElementById('js-month-chart'); + const weekdayContainer = document.getElementById('js-weekday-chart'); + const hourContainer = document.getElementById('js-hour-chart'); - const pieChart = (context, data) => { - const options = pieChartOptions(); - - return new Chart(context, { - type: 'pie', - data, - options, - }); - }; - - const chartData = data => ({ - labels: Object.keys(data), - datasets: [ - { - backgroundColor: 'rgba(220,220,220,0.5)', - borderColor: 'rgba(220,220,220,1)', - borderWidth: 1, - data: _.values(data), - }, - ], - }); + const LANGUAGE_CHART_HEIGHT = 300; const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { if (firstDayOfWeek === 0) { @@ -60,28 +26,115 @@ document.addEventListener('DOMContentLoaded', () => { }, {}); }; - const hourData = chartData(projectChartData.hour); - barChart($('#hour-chart'), hourData); - - const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); - const dayData = chartData(weekDays); - barChart($('#weekday-chart'), dayData); + // eslint-disable-next-line no-new + new Vue({ + el: languagesContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(languagesContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + return { full: this.chartData.map(d => [d.label, d.value]) }; + }, + }, + render(h) { + return h(GlColumnChart, { + props: { + data: this.seriesData, + xAxisTitle: __('Used programming language'), + yAxisTitle: __('Percentage'), + xAxisType: 'category', + }, + attrs: { + height: LANGUAGE_CHART_HEIGHT, + }, + }); + }, + }); - const monthData = chartData(projectChartData.month); - barChart($('#month-chart'), monthData); + // eslint-disable-next-line no-new + new Vue({ + el: monthContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(monthContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + data: this.seriesData, + xAxisTitle: __('Day of month'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + }); + }, + }); - const data = { - datasets: [ - { - data: projectChartData.languages.map(x => x.value), - backgroundColor: projectChartData.languages.map(x => x.color), - hoverBackgroundColor: projectChartData.languages.map(x => x.highlight), + // eslint-disable-next-line no-new + new Vue({ + el: weekdayContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(weekdayContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week); + const data = Object.keys(weekDays).reduce((acc, key) => { + acc.push([key, weekDays[key]]); + return acc; + }, []); + return { full: data }; }, - ], - labels: projectChartData.languages.map(x => x.label), - }; - const ctx = $('#languages-chart') - .get(0) - .getContext('2d'); - pieChart(ctx, data); + }, + render(h) { + return h(GlColumnChart, { + props: { + data: this.seriesData, + xAxisTitle: __('Weekday'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + }); + }, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: hourContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(hourContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + data: this.seriesData, + xAxisTitle: __('Hour (UTC)'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + }); + }, + }); }); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js b/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js new file mode 100644 index 00000000000..941427a1ac3 --- /dev/null +++ b/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js @@ -0,0 +1,11 @@ +export default { + computed: { + seriesData() { + const data = Object.keys(this.chartData).reduce((acc, key) => { + acc.push([key, this.chartData[key]]); + return acc; + }, []); + return { full: data }; + }, + }, +}; diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js index bd8afa2d5ba..5eb0d323266 100644 --- a/app/assets/javascripts/pages/projects/init_blob.js +++ b/app/assets/javascripts/pages/projects/init_blob.js @@ -11,7 +11,7 @@ export default () => { // eslint-disable-next-line no-new new BlobLinePermalinkUpdater( document.querySelector('#blob-content-holder'), - '.diff-line-num[data-line-number]', + '.diff-line-num[data-line-number], .diff-line-num[data-line-number] *', document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), ); @@ -25,6 +25,7 @@ export default () => { new ShortcutsBlob({ skipResetBindings: true, fileBlobPermalinkUrl, + fileBlobPermalinkUrlElement, }); new BlobForkSuggestion({ diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 1f8befc07c8..c4cc667710a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -7,7 +7,6 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initSourcegraph from '~/sourcegraph'; import initPopover from '~/mr_tabs_popover'; -import initWidget from '../../../vue_merge_request_widget'; export default function() { new ZenMode(); // eslint-disable-line no-new @@ -20,7 +19,6 @@ export default function() { new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); howToMerge(); - initWidget(); initSourcegraph(); const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight'); diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js index 9fa580d2ba9..d77b84a3b24 100644 --- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -1,83 +1,3 @@ -import $ from 'jquery'; -import Chart from 'chart.js'; +import initProjectPipelinesChartsApp from '~/projects/pipelines/charts/index'; -import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils'; - -const SUCCESS_LINE_COLOR = '#1aaa55'; - -const TOTAL_LINE_COLOR = '#707070'; - -const buildChart = (chartScope, shouldAdjustFontSize) => { - const data = { - labels: chartScope.labels, - datasets: [ - { - backgroundColor: SUCCESS_LINE_COLOR, - borderColor: SUCCESS_LINE_COLOR, - pointBackgroundColor: SUCCESS_LINE_COLOR, - pointBorderColor: '#fff', - data: chartScope.successValues, - fill: 'origin', - }, - { - backgroundColor: TOTAL_LINE_COLOR, - borderColor: TOTAL_LINE_COLOR, - pointBackgroundColor: TOTAL_LINE_COLOR, - pointBorderColor: '#EEE', - data: chartScope.totalValues, - fill: '-1', - }, - ], - }; - const ctx = $(`#${chartScope.scope}Chart`) - .get(0) - .getContext('2d'); - - return new Chart(ctx, { - type: 'line', - data, - options: lineChartOptions({ - width: ctx.canvas.width, - numberOfPoints: chartScope.totalValues.length, - shouldAdjustFontSize, - }), - }); -}; - -const buildBarChart = (chartTimesData, shouldAdjustFontSize) => { - const data = { - labels: chartTimesData.labels, - datasets: [ - { - backgroundColor: 'rgba(220,220,220,0.5)', - borderColor: 'rgba(220,220,220,1)', - borderWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, - data: chartTimesData.values, - }, - ], - }; - return new Chart( - $('#build_timesChart') - .get(0) - .getContext('2d'), - { - type: 'bar', - data, - options: barChartOptions(shouldAdjustFontSize), - }, - ); -}; - -document.addEventListener('DOMContentLoaded', () => { - const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); - - // Scale fonts if window width lower than 768px (iPad portrait) - const shouldAdjustFontSize = window.innerWidth < 768; - - buildBarChart(chartTimesData, shouldAdjustFontSize); - - chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize)); -}); +document.addEventListener('DOMContentLoaded', initProjectPipelinesChartsApp); diff --git a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js index ba4ae04ab3d..ade6908c4a5 100644 --- a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js +++ b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js @@ -1,6 +1,18 @@ import Pipelines from '~/pipelines'; export default () => { + const mergeRequestListToggle = document.querySelector('.js-toggle-mr-list'); + const truncatedMergeRequestList = document.querySelector('.js-truncated-mr-list'); + const fullMergeRequestList = document.querySelector('.js-full-mr-list'); + + if (mergeRequestListToggle) { + mergeRequestListToggle.addEventListener('click', e => { + e.preventDefault(); + truncatedMergeRequestList.classList.toggle('hide'); + fullMergeRequestList.classList.toggle('hide'); + }); + } + const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document .querySelector('.js-pipeline-tab-link a') diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js index 59310b3f76f..47fea2be189 100644 --- a/app/assets/javascripts/pages/projects/registry/repositories/index.js +++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js @@ -1,3 +1,9 @@ import initRegistryImages from '~/registry/list/index'; +import registryExplorer from '~/registry/explorer/index'; -document.addEventListener('DOMContentLoaded', initRegistryImages); +document.addEventListener('DOMContentLoaded', () => { + initRegistryImages(); + const { attachMainComponent, attachBreadcrumb } = registryExplorer(); + attachBreadcrumb(); + attachMainComponent(); +}); diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js index 98ec196fc37..efa059dcd6d 100644 --- a/app/assets/javascripts/pages/projects/releases/edit/index.js +++ b/app/assets/javascripts/pages/projects/releases/edit/index.js @@ -1,5 +1,5 @@ import ZenMode from '~/zen_mode'; -import initEditRelease from '~/releases/detail'; +import initEditRelease from '~/releases/mount_edit'; document.addEventListener('DOMContentLoaded', () => { new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js index 6402023149f..24c9cd528b3 100644 --- a/app/assets/javascripts/pages/projects/releases/index/index.js +++ b/app/assets/javascripts/pages/projects/releases/index/index.js @@ -1,3 +1,3 @@ -import initReleases from '~/releases/list'; +import initReleases from '~/releases/mount_index'; document.addEventListener('DOMContentLoaded', initReleases); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index ba4b271f09e..2d77f2686f7 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -1,5 +1,6 @@ import IntegrationSettingsForm from '~/integrations/integration_settings_form'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; +import initAlertsSettings from '~/alerts_service_settings'; document.addEventListener('DOMContentLoaded', () => { const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); @@ -10,4 +11,6 @@ document.addEventListener('DOMContentLoaded', () => { const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); prometheusMetrics.loadActiveMetrics(); } + + initAlertsSettings(document.querySelector('.js-alerts-service-settings')); }); diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index 80b62859134..6b02a074abf 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -40,7 +40,7 @@ export default class Wikis { // Replace hyphens with spaces if (title) title = title.replace(/-+/g, ' '); - const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }); + const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }, false); this.commitMessageInput.value = newCommitMessage; } diff --git a/app/assets/javascripts/pages/registrations/welcome/index.js b/app/assets/javascripts/pages/registrations/welcome/index.js deleted file mode 100644 index 2d555fa7977..00000000000 --- a/app/assets/javascripts/pages/registrations/welcome/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import LengthValidator from '~/pages/sessions/new/length_validator'; -import NoEmojiValidator from '~/emoji/no_emoji_validator'; - -document.addEventListener('DOMContentLoaded', () => { - new LengthValidator(); // eslint-disable-line no-new - new NoEmojiValidator(); // eslint-disable-line no-new -}); diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 693125f8a38..4f645e511f9 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -18,7 +18,7 @@ const firstDayOfWeekChoices = Object.freeze({ const LOADING_HTML = ` <div class="text-center"> - <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i> + <div class="spinner spinner-md"></div> </div> `; diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 4ac4efec45d..dafd800099c 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import axios from '~/lib/utils/axios_utils'; import Activities from '~/activities'; import { localTimeAgo } from '~/lib/utils/datetime_utility'; @@ -56,10 +57,8 @@ import UserOverviewBlock from './user_overview_block'; * </div> * </div> * - * <div class="loading-status"> - * <div class="loading"> - * Loading Animation - * </div> + * <div class="loading"> + * Loading Animation * </div> */ @@ -209,7 +208,7 @@ export default class UserTabs { loadActivityCalendar() { const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar'); - if (!$calendarWrap.length) return; + if (!$calendarWrap.length || bp.getBreakpointSize() === 'xs') return; const calendarPath = $calendarWrap.data('calendarPath'); @@ -241,7 +240,7 @@ export default class UserTabs { } toggleLoading(status) { - return this.$parentEl.find('.loading-status .loading').toggleClass('hide', !status); + return this.$parentEl.find('.loading').toggleClass('hide', !status); } setCurrentAction(source) { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 4dc6e51d2fc..6a836adba01 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,5 +1,4 @@ <script> -import _ from 'underscore'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; import GraphMixin from '../../mixins/graph_component_mixin'; @@ -70,7 +69,7 @@ export default { expandedTriggeredBy() { return ( this.pipeline.triggered_by && - _.isArray(this.pipeline.triggered_by) && + Array.isArray(this.pipeline.triggered_by) && this.pipeline.triggered_by.find(el => el.isExpanded) ); }, diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index db7714808fd..3d3dabbdf22 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEmpty, escape as esc } from 'lodash'; import stageColumnMixin from '../../mixins/stage_column_mixin'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; @@ -39,12 +39,12 @@ export default { }, computed: { hasAction() { - return !_.isEmpty(this.action); + return !isEmpty(this.action); }, }, methods: { groupId(group) { - return `ci-badge-${_.escape(group.name)}`; + return `ci-badge-${esc(group.name)}`; }, pipelineActionRequestComplete() { this.$emit('refreshPipelineGraph'); diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 726bba7f9f4..2a3d022c5cd 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,6 +1,7 @@ <script> -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; import { __ } from '~/locale'; @@ -12,6 +13,10 @@ export default { ciHeader, GlLoadingIcon, GlModal, + LoadingButton, + }, + directives: { + GlModal: GlModalDirective, }, props: { pipeline: { @@ -25,7 +30,9 @@ export default { }, data() { return { - actions: this.getActions(), + isCanceling: false, + isRetrying: false, + isDeleting: false, }; }, @@ -43,67 +50,18 @@ export default { }, }, - watch: { - pipeline() { - this.actions = this.getActions(); - }, - }, - methods: { - onActionClicked(action) { - if (action.modal) { - this.$root.$emit('bv::show::modal', action.modal); - } else { - this.postAction(action); - } + cancelPipeline() { + this.isCanceling = true; + eventHub.$emit('headerPostAction', this.pipeline.cancel_path); }, - postAction(action) { - const index = this.actions.indexOf(action); - - this.$set(this.actions[index], 'isLoading', true); - - eventHub.$emit('headerPostAction', action); + retryPipeline() { + this.isRetrying = true; + eventHub.$emit('headerPostAction', this.pipeline.retry_path); }, deletePipeline() { - const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID); - - this.$set(this.actions[index], 'isLoading', true); - - eventHub.$emit('headerDeleteAction', this.actions[index]); - }, - - getActions() { - const actions = []; - - if (this.pipeline.retry_path) { - actions.push({ - label: __('Retry'), - path: this.pipeline.retry_path, - cssClass: 'js-retry-button btn btn-inverted-secondary', - isLoading: false, - }); - } - - if (this.pipeline.cancel_path) { - actions.push({ - label: __('Cancel running'), - path: this.pipeline.cancel_path, - cssClass: 'js-btn-cancel-pipeline btn btn-danger', - isLoading: false, - }); - } - - if (this.pipeline.delete_path) { - actions.push({ - label: __('Delete'), - path: this.pipeline.delete_path, - modal: DELETE_MODAL_ID, - cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted', - isLoading: false, - }); - } - - return actions; + this.isDeleting = true; + eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); }, }, DELETE_MODAL_ID, @@ -117,10 +75,38 @@ export default { :item-id="pipeline.id" :time="pipeline.created_at" :user="pipeline.user" - :actions="actions" item-name="Pipeline" - @actionClicked="onActionClicked" - /> + > + <loading-button + v-if="pipeline.retry_path" + :loading="isRetrying" + :disabled="isRetrying" + class="js-retry-button btn btn-inverted-secondary" + container-class="d-inline" + :label="__('Retry')" + @click="retryPipeline()" + /> + + <loading-button + v-if="pipeline.cancel_path" + :loading="isCanceling" + :disabled="isCanceling" + class="js-btn-cancel-pipeline btn btn-danger" + container-class="d-inline" + :label="__('Cancel running')" + @click="cancelPipeline()" + /> + + <loading-button + v-if="pipeline.delete_path" + v-gl-modal="$options.DELETE_MODAL_ID" + :loading="isDeleting" + :disabled="isDeleting" + class="js-btn-delete-pipeline btn btn-danger btn-inverted" + container-class="d-inline" + :label="__('Delete')" + /> + </ci-header> <gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" /> diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue index 6ca96bbba5e..f604edd8859 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEmpty } from 'lodash'; import { GlLink } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -43,7 +43,7 @@ export default { ); }, hasRef() { - return !_.isEmpty(this.pipeline.ref); + return !isEmpty(this.pipeline.ref); }, }, methods: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 743c3ea271d..0c9d242f509 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -1,11 +1,11 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import _ from 'underscore'; +import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import popover from '~/vue_shared/directives/popover'; const popoverTitle = sprintf( - _.escape( + escape( __( `This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}`, ), @@ -49,7 +49,7 @@ export default { href="${this.autoDevopsHelpPath}" target="_blank" rel="noopener noreferrer nofollow"> - ${_.escape(__('Learn more about Auto DevOps'))} + ${escape(__('Learn more about Auto DevOps'))} </a>`, }; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index d730ef41b1a..accd6bf71f4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isEqual } from 'lodash'; import { __, sprintf, s__ } from '../../locale'; import createFlash from '../../flash'; import PipelinesService from '../services/pipelines_service'; @@ -218,7 +218,7 @@ export default { successCallback(resp) { // Because we are polling & the user is interacting verify if the response received // matches the last request made - if (_.isEqual(resp.config.params, this.requestData)) { + if (isEqual(resp.config.params, this.requestData)) { this.store.storeCount(resp.data.count); this.store.storePagination(resp.headers); this.setCommonData(resp.data.pipelines); diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index afb8439511f..e25f8ab4790 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -75,9 +75,9 @@ export default { * This field needs a lot of verification, because of different possible cases: * * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar - * 3. If GitLab user does not have avatar he/she might have a Gravatar - * 4. If committer is not a GitLab User he/she can have a Gravatar + * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar + * 3. If GitLab user does not have avatar they might have a Gravatar + * 4. If committer is not a GitLab User they can have a Gravatar * 5. We do not have consistent API object in this case * 6. We should improve API and the code * @@ -93,17 +93,17 @@ export default { // 1. person who is an author of a commit might be a GitLab user if (this.pipeline.commit.author) { // 2. if person who is an author of a commit is a GitLab user - // he/she can have a GitLab avatar + // they can have a GitLab avatar if (this.pipeline.commit.author.avatar_url) { commitAuthorInformation = this.pipeline.commit.author; - // 3. If GitLab user does not have avatar he/she might have a Gravatar + // 3. If GitLab user does not have avatar, they might have a Gravatar } else if (this.pipeline.commit.author_gravatar_url) { commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { avatar_url: this.pipeline.commit.author_gravatar_url, }); } - // 4. If committer is not a GitLab User he/she can have a Gravatar + // 4. If committer is not a GitLab User, they can have a Gravatar } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, @@ -331,6 +331,7 @@ export default { :loading="isRetrying" :disabled="isRetrying" container-class="js-pipelines-retry-button btn btn-default btn-retry" + data-qa-selector="pipeline_retry_button" @click="handleRetryClick" > <icon name="repeat" /> diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js index f383a4b3368..53b7a174517 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape } from 'lodash'; export default { props: { @@ -18,7 +18,7 @@ export default { }, methods: { capitalizeStageName(name) { - const escapedName = _.escape(name); + const escapedName = escape(name); return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); }, isFirstColumn(index) { diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index c76869d90d5..1d9366f26df 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -59,7 +59,7 @@ export default { }, requestRefreshPipelineGraph() { // When an action is clicked - // (wether in the dropdown or in the main nodes, we refresh the big graph) + // (whether in the dropdown or in the main nodes, we refresh the big graph) this.mediator .refreshPipeline() .catch(() => flash(__('An error occurred while making the request.'))); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index c874c4c6fdd..d9192d3d76b 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,6 +10,7 @@ import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import testReportsStore from './stores/test_reports'; +import axios from '~/lib/utils/axios_utils'; Vue.use(Translate); @@ -70,16 +71,16 @@ export default () => { eventHub.$off('headerDeleteAction', this.deleteAction); }, methods: { - postAction(action) { + postAction(path) { this.mediator.service - .postAction(action.path) + .postAction(path) .then(() => this.mediator.refreshPipeline()) .catch(() => Flash(__('An error occurred while making the request.'))); }, - deleteAction(action) { + deleteAction(path) { this.mediator.stopPipelinePoll(); this.mediator.service - .deleteAction(action.path) + .deleteAction(path) .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success'))) .catch(() => Flash(__('An error occurred while deleting the pipeline.'))); }, @@ -98,8 +99,26 @@ export default () => { window.gon && window.gon.features && window.gon.features.junitPipelineView; if (testReportsEnabled) { + const fetchReportsAction = 'fetchReports'; testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); - testReportsStore.dispatch('fetchReports'); + + const tabsElmement = document.querySelector('.pipelines-tabs'); + const isTestTabActive = Boolean( + document.querySelector('.pipelines-tabs > li > a.test-tab.active'), + ); + + if (isTestTabActive) { + testReportsStore.dispatch(fetchReportsAction); + } else { + const tabClickHandler = e => { + if (e.target.className === 'test-tab') { + testReportsStore.dispatch(fetchReportsAction); + tabsElmement.removeEventListener('click', tabClickHandler); + } + }; + + tabsElmement.addEventListener('click', tabClickHandler); + } // eslint-disable-next-line no-new new Vue({ @@ -111,5 +130,12 @@ export default () => { return createElement('test-reports'); }, }); + + axios + .get(dataset.testReportsCountEndpoint) + .then(({ data }) => { + document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; + }) + .catch(() => {}); } }; diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 441c9f3c25f..69e3579a3c7 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import _ from 'underscore'; export default class PipelineStore { constructor() { @@ -61,7 +60,7 @@ export default class PipelineStore { Vue.set(newPipeline, 'isLoading', false); if (newPipeline.triggered_by) { - if (!_.isArray(newPipeline.triggered_by)) { + if (!Array.isArray(newPipeline.triggered_by)) { Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] }); } this.parseTriggeredByPipelines(oldPipeline, newPipeline.triggered_by[0]); diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue new file mode 100644 index 00000000000..4dc1c512689 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -0,0 +1,145 @@ +<script> +import dateFormat from 'dateformat'; +import { __, sprintf } from '~/locale'; +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { getDateInPast } from '~/lib/utils/datetime_utility'; +import StatisticsList from './statistics_list.vue'; +import PipelinesAreaChart from './pipelines_area_chart.vue'; +import { + CHART_CONTAINER_HEIGHT, + INNER_CHART_HEIGHT, + X_AXIS_LABEL_ROTATION, + X_AXIS_TITLE_OFFSET, + CHART_DATE_FORMAT, + ONE_WEEK_AGO_DAYS, + ONE_MONTH_AGO_DAYS, +} from '../constants'; + +export default { + components: { + StatisticsList, + GlColumnChart, + PipelinesAreaChart, + }, + props: { + counts: { + type: Object, + required: true, + }, + timesChartData: { + type: Object, + required: true, + }, + lastWeekChartData: { + type: Object, + required: true, + }, + lastMonthChartData: { + type: Object, + required: true, + }, + lastYearChartData: { + type: Object, + required: true, + }, + }, + data() { + return { + timesChartTransformedData: { + full: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), + }, + }; + }, + computed: { + areaCharts() { + const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles; + + return [ + this.buildAreaChartData(lastWeek, this.lastWeekChartData), + this.buildAreaChartData(lastMonth, this.lastMonthChartData), + this.buildAreaChartData(lastYear, this.lastYearChartData), + ]; + }, + }, + methods: { + mergeLabelsAndValues(labels, values) { + return labels.map((label, index) => [label, values[index]]); + }, + buildAreaChartData(title, data) { + const { labels, totals, success } = data; + + return { + title, + data: [ + { + name: 'all', + data: this.mergeLabelsAndValues(labels, totals), + }, + { + name: 'success', + data: this.mergeLabelsAndValues(labels, success), + }, + ], + }; + }, + }, + chartContainerHeight: CHART_CONTAINER_HEIGHT, + timesChartOptions: { + height: INNER_CHART_HEIGHT, + xAxis: { + axisLabel: { + rotate: X_AXIS_LABEL_ROTATION, + }, + nameGap: X_AXIS_TITLE_OFFSET, + }, + }, + get chartTitles() { + const today = dateFormat(new Date(), CHART_DATE_FORMAT); + const pastDate = timeScale => + dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT); + return { + lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), { + oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS), + today, + }), + lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), { + oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS), + today, + }), + lastYear: __('Pipelines for last year'), + }; + }, +}; +</script> +<template> + <div> + <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> + <div class="row"> + <div class="col-md-6"> + <statistics-list :counts="counts" /> + </div> + <div class="col-md-6"> + <strong> + {{ __('Duration for the last 30 commits') }} + </strong> + <gl-column-chart + :height="$options.chartContainerHeight" + :option="$options.timesChartOptions" + :data="timesChartTransformedData" + :y-axis-title="__('Minutes')" + :x-axis-title="__('Commit')" + x-axis-type="category" + /> + </div> + </div> + <hr /> + <h4 class="my-4">{{ __('Pipelines charts') }}</h4> + <pipelines-area-chart + v-for="(chart, index) in areaCharts" + :key="index" + :chart-data="chart.data" + > + {{ chart.title }} + </pipelines-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue new file mode 100644 index 00000000000..d701f238a2e --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue @@ -0,0 +1,46 @@ +<script> +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { CHART_CONTAINER_HEIGHT } from '../constants'; + +export default { + components: { + GlAreaChart, + ResizableChartContainer, + }, + props: { + chartData: { + type: Array, + required: true, + }, + }, + areaChartOptions: { + xAxis: { + name: s__('Pipeline|Date'), + type: 'category', + }, + yAxis: { + name: s__('Pipeline|Pipelines'), + }, + }, + chartContainerHeight: CHART_CONTAINER_HEIGHT, +}; +</script> +<template> + <div class="prepend-top-default"> + <p> + <slot></slot> + </p> + <resizable-chart-container> + <gl-area-chart + slot-scope="{ width }" + :width="width" + :height="$options.chartContainerHeight" + :data="chartData" + :include-legend-avg-max="false" + :option="$options.areaChartOptions" + /> + </resizable-chart-container> + </div> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue new file mode 100644 index 00000000000..cd9e464c5ac --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -0,0 +1,30 @@ +<script> +export default { + props: { + counts: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <ul> + <li> + <span>{{ s__('PipelineCharts|Total:') }}</span> + <strong>{{ n__('1 pipeline', '%d pipelines', counts.total) }}</strong> + </li> + <li> + <span>{{ s__('PipelineCharts|Successful:') }}</span> + <strong>{{ n__('1 pipeline', '%d pipelines', counts.success) }}</strong> + </li> + <li> + <span>{{ s__('PipelineCharts|Failed:') }}</span> + <strong>{{ n__('1 pipeline', '%d pipelines', counts.failed) }}</strong> + </li> + <li> + <span>{{ s__('PipelineCharts|Success ratio:') }}</span> + <strong>{{ counts.successRatio }}%</strong> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js new file mode 100644 index 00000000000..5dbe3c01100 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/constants.js @@ -0,0 +1,13 @@ +export const CHART_CONTAINER_HEIGHT = 300; + +export const INNER_CHART_HEIGHT = 200; + +export const X_AXIS_LABEL_ROTATION = 45; + +export const X_AXIS_TITLE_OFFSET = 60; + +export const ONE_WEEK_AGO_DAYS = 7; + +export const ONE_MONTH_AGO_DAYS = 31; + +export const CHART_DATE_FORMAT = 'dd mmm'; diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js new file mode 100644 index 00000000000..4ae2b729200 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import ProjectPipelinesCharts from './components/app.vue'; + +export default () => { + const el = document.querySelector('#js-project-pipelines-charts-app'); + const { + countsFailed, + countsSuccess, + countsTotal, + successRatio, + timesChartLabels, + timesChartValues, + lastWeekChartLabels, + lastWeekChartTotals, + lastWeekChartSuccess, + lastMonthChartLabels, + lastMonthChartTotals, + lastMonthChartSuccess, + lastYearChartLabels, + lastYearChartTotals, + lastYearChartSuccess, + } = el.dataset; + + const parseAreaChartData = (labels, totals, success) => ({ + labels: JSON.parse(labels), + totals: JSON.parse(totals), + success: JSON.parse(success), + }); + + return new Vue({ + el, + name: 'ProjectPipelinesChartsApp', + components: { + ProjectPipelinesCharts, + }, + render: createElement => + createElement(ProjectPipelinesCharts, { + props: { + counts: { + failed: countsFailed, + success: countsSuccess, + total: countsTotal, + successRatio, + }, + timesChartData: { + labels: JSON.parse(timesChartLabels), + values: JSON.parse(timesChartValues), + }, + lastWeekChartData: parseAreaChartData( + lastWeekChartLabels, + lastWeekChartTotals, + lastWeekChartSuccess, + ), + lastMonthChartData: parseAreaChartData( + lastMonthChartLabels, + lastMonthChartTotals, + lastMonthChartSuccess, + ), + lastYearChartData: parseAreaChartData( + lastYearChartLabels, + lastYearChartTotals, + lastYearChartSuccess, + ), + }, + }), + }); +}; diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue new file mode 100644 index 00000000000..a29a9bd23c3 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue @@ -0,0 +1,39 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { mapState } from 'vuex'; + +export default { + name: 'GroupEmptyState', + components: { + GlEmptyState, + GlSprintf, + GlLink, + }, + computed: { + ...mapState(['config']), + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images available in this group')" + :svg-path="config.noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text"> + <gl-sprintf + :message=" + s__( + `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`, + ) + " + > + <template #docLink="{content}"> + <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue new file mode 100644 index 00000000000..53853b4b9fb --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue @@ -0,0 +1,113 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { mapState } from 'vuex'; + +export default { + name: 'ProjectEmptyState', + components: { + ClipboardButton, + GlEmptyState, + GlSprintf, + GlLink, + }, + computed: { + ...mapState(['config']), + dockerBuildCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker build -t ${this.config.repositoryUrl} .`; + }, + dockerPushCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker push ${this.config.repositoryUrl}`; + }, + dockerLoginCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker login ${this.config.registryHostUrlWithPort}`; + }, + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images stored for this project')" + :svg-path="config.noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text"> + <gl-sprintf + :message=" + s__(`ContainerRegistry|With the Container Registry, every project can have its own space to + store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`) + " + > + <template #docLink="{content}"> + <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p class="js-not-logged-in-to-registry-text"> + <gl-sprintf + :message=" + s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to + the Container Registry by using your GitLab username and password. If you have + %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a + %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} + instead of a password.`) + " + > + <template #twofaDocLink="{content}"> + <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link> + </template> + <template #personalAccessTokensDocLink="{content}"> + <gl-link :href="config.personalAccessTokensHelpLink" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + <div class="input-group append-bottom-10"> + <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerLoginCommand" + :title="s__('ContainerRegistry|Copy login command')" + class="input-group-text" + /> + </span> + </div> + <p></p> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> + + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command')" + class="input-group-text" + /> + </span> + </div> + + <div class="input-group"> + <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerPushCommand" + :title="s__('ContainerRegistry|Copy push command')" + class="input-group-text" + /> + </span> + </div> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue new file mode 100644 index 00000000000..f51948da8cc --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue @@ -0,0 +1,59 @@ +<script> +import { initial, first, last } from 'lodash'; + +export default { + props: { + crumbs: { + type: Array, + required: true, + }, + }, + computed: { + rootRoute() { + return this.$router.options.routes.find(r => r.meta.root); + }, + isRootRoute() { + return this.$route.name === this.rootRoute.name; + }, + rootCrumbs() { + return initial(this.crumbs); + }, + divider() { + const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg'); + return { classList: [...classList], tagName, innerHTML }; + }, + lastCrumb() { + const { children } = last(this.crumbs); + const { tagName, classList } = first(children); + return { + tagName, + classList: [...classList], + text: this.$route.meta.nameGenerator(this.$route), + path: { to: this.$route.name }, + }; + }, + }, +}; +</script> + +<template> + <ul> + <li + v-for="(crumb, index) in rootCrumbs" + :key="index" + :class="crumb.classList" + v-html="crumb.innerHTML" + ></li> + <li v-if="!isRootRoute"> + <router-link ref="rootRouteLink" :to="rootRoute.path"> + {{ rootRoute.meta.nameGenerator(rootRoute) }} + </router-link> + <component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" /> + </li> + <li> + <component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.classList"> + <router-link ref="childRouteLink" :to="lastCrumb.path">{{ lastCrumb.text }}</router-link> + </component> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js new file mode 100644 index 00000000000..bb311157627 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -0,0 +1,32 @@ +import { __ } from '~/locale'; + +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = __( + 'Something went wrong while fetching the packages list.', +); +export const FETCH_TAGS_LIST_ERROR_MESSAGE = __( + 'Something went wrong while fetching the tags list.', +); + +export const DELETE_IMAGE_ERROR_MESSAGE = __('Something went wrong while deleting the image.'); +export const DELETE_IMAGE_SUCCESS_MESSAGE = __('Image deleted successfully'); +export const DELETE_TAG_ERROR_MESSAGE = __('Something went wrong while deleting the tag.'); +export const DELETE_TAG_SUCCESS_MESSAGE = __('Tag deleted successfully'); +export const DELETE_TAGS_ERROR_MESSAGE = __('Something went wrong while deleting the tags.'); +export const DELETE_TAGS_SUCCESS_MESSAGE = __('Tags deleted successfully'); + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 10; + +export const GROUP_PAGE_TYPE = 'groups'; + +export const LIST_KEY_TAG = 'name'; +export const LIST_KEY_IMAGE_ID = 'short_revision'; +export const LIST_KEY_SIZE = 'total_size'; +export const LIST_KEY_LAST_UPDATED = 'created_at'; +export const LIST_KEY_ACTIONS = 'actions'; +export const LIST_KEY_CHECKBOX = 'checkbox'; + +export const LIST_LABEL_TAG = __('Tag'); +export const LIST_LABEL_IMAGE_ID = __('Image ID'); +export const LIST_LABEL_SIZE = __('Size'); +export const LIST_LABEL_LAST_UPDATED = __('Last Updated'); diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js new file mode 100644 index 00000000000..a36978303c6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/index.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import RegistryExplorer from './pages/index.vue'; +import RegistryBreadcrumb from './components/registry_breadcrumb.vue'; +import { createStore } from './stores'; +import createRouter from './router'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-container-registry'); + + if (!el) { + return null; + } + + const { endpoint } = el.dataset; + + const store = createStore(); + const router = createRouter(endpoint, store); + store.dispatch('setInitialState', el.dataset); + + const attachMainComponent = () => + new Vue({ + el, + store, + router, + components: { + RegistryExplorer, + }, + render(createElement) { + return createElement('registry-explorer'); + }, + }); + + const attachBreadcrumb = () => { + const breadCrumbEl = document.querySelector('nav .js-breadcrumbs-list'); + const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')]; + return new Vue({ + el: breadCrumbEl, + store, + router, + components: { + RegistryBreadcrumb, + }, + render(createElement) { + return createElement('registry-breadcrumb', { + class: breadCrumbEl.className, + props: { + crumbs, + }, + }); + }, + }); + }; + + return { attachBreadcrumb, attachMainComponent }; +}; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue new file mode 100644 index 00000000000..bc613db8672 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -0,0 +1,333 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlTable, + GlFormCheckbox, + GlButton, + GlIcon, + GlTooltipDirective, + GlPagination, + GlModal, + GlLoadingIcon, + GlSprintf, + GlEmptyState, + GlResizeObserverDirective, +} from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { n__, s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Tracking from '~/tracking'; +import { decodeAndParse } from '../utils'; +import { + LIST_KEY_TAG, + LIST_KEY_IMAGE_ID, + LIST_KEY_SIZE, + LIST_KEY_LAST_UPDATED, + LIST_KEY_ACTIONS, + LIST_KEY_CHECKBOX, + LIST_LABEL_TAG, + LIST_LABEL_IMAGE_ID, + LIST_LABEL_SIZE, + LIST_LABEL_LAST_UPDATED, +} from '../constants'; + +export default { + components: { + GlTable, + GlFormCheckbox, + GlButton, + GlIcon, + ClipboardButton, + GlPagination, + GlModal, + GlLoadingIcon, + GlSprintf, + GlEmptyState, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + mixins: [timeagoMixin, Tracking.mixin()], + data() { + return { + selectedItems: [], + itemsToBeDeleted: [], + selectAllChecked: false, + modalDescription: null, + isDesktop: true, + }; + }, + computed: { + ...mapState(['tags', 'tagsPagination', 'isLoading', 'config']), + imageName() { + const { name } = decodeAndParse(this.$route.params.id); + return name; + }, + fields() { + return [ + { key: LIST_KEY_CHECKBOX, label: '' }, + { key: LIST_KEY_TAG, label: LIST_LABEL_TAG }, + { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, + { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, + { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, + { key: LIST_KEY_ACTIONS, label: '' }, + ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); + }, + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, + modalAction() { + return n__( + 'ContainerRegistry|Remove tag', + 'ContainerRegistry|Remove tags', + this.isMultiDelete ? this.itemsToBeDeleted.length : 1, + ); + }, + currentPage: { + get() { + return this.tagsPagination.page; + }, + set(page) { + this.requestTagsList({ pagination: { page }, id: this.$route.params.id }); + }, + }, + }, + methods: { + ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), + setModalDescription(itemIndex = -1) { + if (itemIndex === -1) { + this.modalDescription = { + message: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`), + item: this.itemsToBeDeleted.length, + }; + } else { + const { path } = this.tags[itemIndex]; + + this.modalDescription = { + message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`), + item: path, + }; + } + }, + formatSize(size) { + return numberToHumanSize(size); + }, + layers(layers) { + return layers ? n__('%d layer', '%d layers', layers) : ''; + }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.deselectAll(); + } else { + this.selectAll(); + } + }, + selectAll() { + this.selectedItems = this.tags.map((x, index) => index); + this.selectAllChecked = true; + }, + deselectAll() { + this.selectedItems = []; + this.selectAllChecked = false; + }, + updateSelectedItems(index) { + const delIndex = this.selectedItems.findIndex(x => x === index); + + if (delIndex > -1) { + this.selectedItems.splice(delIndex, 1); + this.selectAllChecked = false; + } else { + this.selectedItems.push(index); + + if (this.selectedItems.length === this.tags.length) { + this.selectAllChecked = true; + } + } + }, + deleteSingleItem(index) { + this.setModalDescription(index); + this.itemsToBeDeleted = [index]; + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + deleteMultipleItems() { + this.itemsToBeDeleted = [...this.selectedItems]; + if (this.selectedItems.length === 1) { + this.setModalDescription(this.itemsToBeDeleted[0]); + } else if (this.selectedItems.length > 1) { + this.setModalDescription(); + } + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + handleSingleDelete(itemToDelete) { + this.itemsToBeDeleted = []; + this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }); + }, + handleMultipleDelete() { + const { itemsToBeDeleted } = this; + this.itemsToBeDeleted = []; + this.selectedItems = []; + + this.requestDeleteTags({ + ids: itemsToBeDeleted.map(x => this.tags[x].name), + params: this.$route.params.id, + }); + }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.tags[index]); + } + }, + handleResize() { + this.isDesktop = GlBreakpointInstance.isDesktop(); + }, + }, +}; +</script> + +<template> + <div + v-gl-resize-observer="handleResize" + class="my-3 position-absolute w-100 slide-enter-to-element" + > + <div class="d-flex my-3 align-items-center"> + <h4> + <gl-sprintf :message="s__('ContainerRegistry|%{imageName} tags')"> + <template #imageName> + {{ imageName }} + </template> + </gl-sprintf> + </h4> + </div> + <gl-loading-icon v-if="isLoading" /> + <template v-else-if="tags.length > 0"> + <gl-table :items="tags" :fields="fields" :stacked="!isDesktop"> + <template v-if="isDesktop" #head(checkbox)> + <gl-form-checkbox + ref="mainCheckbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </template> + <template #head(actions)> + <gl-button + ref="bulkDeleteButton" + v-gl-tooltip + :disabled="!selectedItems || selectedItems.length === 0" + class="float-right" + variant="danger" + :title="s__('ContainerRegistry|Remove selected tags')" + :aria-label="s__('ContainerRegistry|Remove selected tags')" + @click="deleteMultipleItems()" + > + <gl-icon name="remove" /> + </gl-button> + </template> + + <template #cell(checkbox)="{index}"> + <gl-form-checkbox + ref="rowCheckbox" + class="js-row-checkbox" + :checked="selectedItems.includes(index)" + @change="updateSelectedItems(index)" + /> + </template> + <template #cell(name)="{item}"> + <span ref="rowName"> + {{ item.name }} + </span> + <clipboard-button + v-if="item.location" + ref="rowClipboardButton" + :title="item.location" + :text="item.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </template> + <template #cell(short_revision)="{value}"> + <span ref="rowShortRevision"> + {{ value }} + </span> + </template> + <template #cell(total_size)="{item}"> + <span ref="rowSize"> + {{ formatSize(item.total_size) }} + <template v-if="item.total_size && item.layers"> + · + </template> + {{ layers(item.layers) }} + </span> + </template> + <template #cell(created_at)="{value}"> + <span ref="rowTime"> + {{ timeFormatted(value) }} + </span> + </template> + <template #cell(actions)="{index, item}"> + <gl-button + ref="singleDeleteButton" + :title="s__('ContainerRegistry|Remove tag')" + :aria-label="s__('ContainerRegistry|Remove tag')" + :disabled="!item.destroy_path" + variant="danger" + :class="['js-delete-registry float-right btn-inverted btn-border-color btn-icon']" + @click="deleteSingleItem(index)" + > + <gl-icon name="remove" /> + </gl-button> + </template> + </gl-table> + <gl-pagination + ref="pagination" + v-model="currentPage" + :per-page="tagsPagination.perPage" + :total-items="tagsPagination.total" + align="center" + class="w-100" + /> + <gl-modal + ref="deleteModal" + modal-id="delete-tag-modal" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <p v-if="modalDescription"> + <gl-sprintf :message="modalDescription.message"> + <template #item> + <b>{{ modalDescription.item }}</b> + </template> + </gl-sprintf> + </p> + </gl-modal> + </template> + <gl-empty-state + v-else + :title="s__('ContainerRegistry|This image has no active tags')" + :svg-path="config.noContainersImage" + :description=" + s__( + `ContainerRegistry|The last tag related to this image was recently removed. + This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. + If you have any questions, contact your administrator.`, + ) + " + class="mx-auto my-0" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue new file mode 100644 index 00000000000..deefbfc40e0 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -0,0 +1,11 @@ +<script> +export default {}; +</script> + +<template> + <div class="position-relative"> + <transition name="slide"> + <router-view /> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue new file mode 100644 index 00000000000..1dbc7cc2242 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -0,0 +1,214 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlLoadingIcon, + GlEmptyState, + GlPagination, + GlTooltipDirective, + GlButton, + GlIcon, + GlModal, + GlSprintf, + GlLink, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ProjectEmptyState from '../components/project_empty_state.vue'; +import GroupEmptyState from '../components/group_empty_state.vue'; + +export default { + name: 'RegistryListApp', + components: { + GlEmptyState, + GlLoadingIcon, + GlPagination, + ProjectEmptyState, + GroupEmptyState, + ClipboardButton, + GlButton, + GlIcon, + GlModal, + GlSprintf, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin()], + data() { + return { + itemToDelete: {}, + }; + }, + computed: { + ...mapState(['config', 'isLoading', 'images', 'pagination']), + tracking() { + return { + label: 'registry_repository_delete', + }; + }, + currentPage: { + get() { + return this.pagination.page; + }, + set(page) { + this.requestImagesList({ page }); + }, + }, + }, + methods: { + ...mapActions(['requestImagesList', 'requestDeleteImage']), + deleteImage(item) { + // This event is already tracked in the system and so the name must be kept to aggregate the data + this.track('click_button'); + this.itemToDelete = item; + this.$refs.deleteModal.show(); + }, + handleDeleteRepository() { + this.track('confirm_delete'); + this.requestDeleteImage(this.itemToDelete.destroy_path); + this.itemToDelete = {}; + }, + encodeListItem(item) { + const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); + return window.btoa(params); + }, + }, +}; +</script> + +<template> + <div class="position-absolute w-100 slide-enter-from-element"> + <gl-empty-state + v-if="config.characterError" + :title="s__('ContainerRegistry|Docker connection error')" + :svg-path="config.containersErrorImage" + > + <template #description> + <p> + <gl-sprintf + :message=" + s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an + issue with your project name or path. + %{docLinkStart}More Information%{docLinkEnd}`) + " + > + <template #docLink="{content}"> + <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> + + <template v-else> + <gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" /> + + <template v-else> + <div v-if="images.length" ref="imagesList"> + <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> + <p> + <gl-sprintf + :message=" + s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every + project can have its own space to store its Docker images. + %{docLinkStart}More Information%{docLinkEnd}`) + " + > + <template #docLink="{content}"> + <gl-link :href="config.helpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <div class="d-flex flex-column"> + <div + v-for="(listItem, index) in images" + :key="index" + ref="rowItem" + :class="[ + 'd-flex justify-content-between align-items-center py-2 border-bottom', + { 'border-top': index === 0 }, + ]" + > + <div> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title=" + s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', + ) + " + > + <gl-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path" + :title="s__('ContainerRegistry|Remove repository')" + :aria-label="s__('ContainerRegistry|Remove repository')" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-button> + </div> + </div> + </div> + <gl-pagination + v-model="currentPage" + :per-page="pagination.perPage" + :total-items="pagination.total" + align="center" + class="w-100 mt-2" + /> + </div> + <template v-else> + <project-empty-state v-if="!config.isGroupPage" /> + <group-empty-state v-else /> + </template> + </template> + + <gl-modal + ref="deleteModal" + modal-id="delete-image-modal" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete')" + > + <template #modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> + <p> + <gl-sprintf + :message=" s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', + )," + > + <template #title> + <b>{{ itemToDelete.path }}</b> + </template> + </gl-sprintf> + </p> + <template #modal-ok>{{ __('Remove') }}</template> + </gl-modal> + </template> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js new file mode 100644 index 00000000000..7e4c3d28623 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/router.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { s__ } from '~/locale'; +import List from './pages/list.vue'; +import Details from './pages/details.vue'; +import { decodeAndParse } from './utils'; + +Vue.use(VueRouter); + +export default function createRouter(base, store) { + const router = new VueRouter({ + base, + mode: 'history', + routes: [ + { + name: 'list', + path: '/', + component: List, + meta: { + nameGenerator: () => s__('ContainerRegistry|Container Registry'), + root: true, + }, + beforeEnter: (to, from, next) => { + store.dispatch('requestImagesList'); + next(); + }, + }, + { + name: 'details', + path: '/:id', + component: Details, + meta: { + nameGenerator: route => decodeAndParse(route.params.id).name, + }, + beforeEnter: (to, from, next) => { + store.dispatch('requestTagsList', { params: to.params.id }); + next(); + }, + }, + ], + }); + + return router; +} diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js new file mode 100644 index 00000000000..86d00d4fca9 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -0,0 +1,117 @@ +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import * as types from './mutation_types'; +import { + FETCH_IMAGES_LIST_ERROR_MESSAGE, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + FETCH_TAGS_LIST_ERROR_MESSAGE, + DELETE_TAG_SUCCESS_MESSAGE, + DELETE_TAG_ERROR_MESSAGE, + DELETE_TAGS_SUCCESS_MESSAGE, + DELETE_TAGS_ERROR_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + DELETE_IMAGE_SUCCESS_MESSAGE, +} from '../constants'; +import { decodeAndParse } from '../utils'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); + +export const receiveImagesListSuccess = ({ commit }, { data, headers }) => { + commit(types.SET_IMAGES_LIST_SUCCESS, data); + commit(types.SET_PAGINATION, headers); +}; + +export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { + commit(types.SET_TAGS_LIST_SUCCESS, data); + commit(types.SET_TAGS_PAGINATION, headers); +}; + +export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => { + commit(types.SET_MAIN_LOADING, true); + const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; + + return axios + .get(state.config.endpoint, { params: { page, per_page: perPage } }) + .then(({ data, headers }) => { + dispatch('receiveImagesListSuccess', { data, headers }); + }) + .catch(() => { + createFlash(FETCH_IMAGES_LIST_ERROR_MESSAGE); + }) + .finally(() => { + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export const requestTagsList = ({ commit, dispatch }, { pagination = {}, params }) => { + commit(types.SET_MAIN_LOADING, true); + const { tags_path } = decodeAndParse(params); + + const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; + return axios + .get(tags_path, { params: { page, per_page: perPage } }) + .then(({ data, headers }) => { + dispatch('receiveTagsListSuccess', { data, headers }); + }) + .catch(() => { + createFlash(FETCH_TAGS_LIST_ERROR_MESSAGE); + }) + .finally(() => { + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) => { + commit(types.SET_MAIN_LOADING, true); + return axios + .delete(tag.destroy_path) + .then(() => { + createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success'); + dispatch('requestTagsList', { pagination: state.tagsPagination, params }); + }) + .catch(() => { + createFlash(DELETE_TAG_ERROR_MESSAGE); + }) + .finally(() => { + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => { + commit(types.SET_MAIN_LOADING, true); + const { id } = decodeAndParse(params); + const url = `/${state.config.projectPath}/registry/repository/${id}/tags/bulk_destroy`; + + return axios + .delete(url, { params: { ids } }) + .then(() => { + createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success'); + dispatch('requestTagsList', { pagination: state.tagsPagination, params }); + }) + .catch(() => { + createFlash(DELETE_TAGS_ERROR_MESSAGE); + }) + .finally(() => { + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { + commit(types.SET_MAIN_LOADING, true); + + return axios + .delete(destroyPath) + .then(() => { + dispatch('requestImagesList', { pagination: state.pagination }); + createFlash(DELETE_IMAGE_SUCCESS_MESSAGE, 'success'); + }) + .catch(() => { + createFlash(DELETE_IMAGE_ERROR_MESSAGE); + }) + .finally(() => { + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export default () => {}; diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/registry/explorer/stores/index.js index e8623a49356..91a35aac149 100644 --- a/app/assets/javascripts/releases/detail/store/index.js +++ b/app/assets/javascripts/registry/explorer/stores/index.js @@ -6,9 +6,11 @@ import state from './state'; Vue.use(Vuex); -export default () => +export const createStore = () => new Vuex.Store({ + state, actions, mutations, - state, }); + +export default createStore(); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js new file mode 100644 index 00000000000..92b747dffc5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -0,0 +1,7 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const SET_PAGINATION = 'SET_PAGINATION'; +export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; +export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; +export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js new file mode 100644 index 00000000000..a2c6a11de20 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -0,0 +1,33 @@ +import * as types from './mutation_types'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; + +export default { + [types.SET_INITIAL_STATE](state, config) { + state.config = { + ...config, + isGroupPage: config.isGroupPage !== undefined, + }; + }, + + [types.SET_IMAGES_LIST_SUCCESS](state, images) { + state.images = images; + }, + + [types.SET_TAGS_LIST_SUCCESS](state, tags) { + state.tags = tags; + }, + + [types.SET_MAIN_LOADING](state, isLoading) { + state.isLoading = isLoading; + }, + + [types.SET_PAGINATION](state, headers) { + const normalizedHeaders = normalizeHeaders(headers); + state.pagination = parseIntPagination(normalizedHeaders); + }, + + [types.SET_TAGS_PAGINATION](state, headers) { + const normalizedHeaders = normalizeHeaders(headers); + state.tagsPagination = parseIntPagination(normalizedHeaders); + }, +}; diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js new file mode 100644 index 00000000000..91a378f139b --- /dev/null +++ b/app/assets/javascripts/registry/explorer/stores/state.js @@ -0,0 +1,8 @@ +export default () => ({ + isLoading: false, + config: {}, + images: [], + tags: [], + pagination: {}, + tagsPagination: {}, +}); diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js new file mode 100644 index 00000000000..b1df87c6993 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/utils.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const decodeAndParse = param => JSON.parse(window.atob(param)); diff --git a/app/assets/javascripts/registry/list/index.js b/app/assets/javascripts/registry/list/index.js index 3d0ff327b42..e8e54fda169 100644 --- a/app/assets/javascripts/registry/list/index.js +++ b/app/assets/javascripts/registry/list/index.js @@ -4,14 +4,20 @@ import Translate from '~/vue_shared/translate'; Vue.use(Translate); -export default () => - new Vue({ - el: '#js-vue-registry-images', +export default () => { + const el = document.getElementById('js-vue-registry-images'); + + if (!el) { + return null; + } + + return new Vue({ + el, components: { registryApp, }, data() { - const { dataset } = document.querySelector(this.$options.el); + const { dataset } = el; return { registryData: { endpoint: dataset.endpoint, @@ -35,3 +41,4 @@ export default () => }); }, }); +}; diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index ca495cd2eca..87e65d354bb 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,20 +1,31 @@ <script> -import { mapState, mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; + import SettingsForm from './settings_form.vue'; export default { components: { - GlLoadingIcon, SettingsForm, + GlAlert, + GlSprintf, + GlLink, + }, + i18n: { + unavailableFeatureText: s__( + 'ContainerRegistry|Currently, the Container Registry tag expiration feature is not available for projects created before GitLab version 12.8. For updates and more information, visit Issue %{linkStart}#196124%{linkEnd}', + ), }, computed: { - ...mapState({ - isLoading: 'isLoading', - }), + ...mapState(['isDisabled']), }, mounted() { - this.fetchSettings(); + this.fetchSettings().catch(() => + this.$toast.show(FETCH_SETTINGS_ERROR_MESSAGE, { type: 'error' }), + ); }, methods: { ...mapActions(['fetchSettings']), @@ -37,7 +48,17 @@ export default { }} </li> </ul> - <gl-loading-icon v-if="isLoading" ref="loading-icon" size="xl" /> - <settings-form v-else ref="settings-form" /> + <settings-form v-if="!isDisabled" /> + <gl-alert v-else :dismissible="false"> + <p> + <gl-sprintf :message="$options.i18n.unavailableFeatureText"> + <template #link="{content}"> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/196124" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 457bf35daab..cab3c7fff85 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,175 +1,95 @@ <script> -import { mapActions, mapState } from 'vuex'; -import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton, GlCard } from '@gitlab/ui'; -import { s__, __, sprintf } from '~/locale'; -import { NAME_REGEX_LENGTH } from '../constants'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '../../shared/constants'; import { mapComputed } from '~/vuex_shared/bindings'; +import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; export default { components: { - GlFormGroup, - GlToggle, - GlFormSelect, - GlFormTextarea, - GlButton, GlCard, + GlButton, + GlLoadingIcon, + ExpirationPolicyFields, }, + mixins: [Tracking.mixin()], labelsConfig: { cols: 3, align: 'right', }, + data() { + return { + tracking: { + label: 'docker_container_retention_and_expiration_policies', + }, + formIsValid: true, + }; + }, computed: { - ...mapState(['formOptions']), - ...mapComputed( - [ - 'enabled', - { key: 'cadence', getter: 'getCadence' }, - { key: 'older_than', getter: 'getOlderThan' }, - { key: 'keep_n', getter: 'getKeepN' }, - 'name_regex', - ], - 'updateSettings', - 'settings', - ), - policyEnabledText() { - return this.enabled ? __('enabled') : __('disabled'); - }, - toggleDescriptionText() { - return sprintf( - s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'), - { - toggleStatus: `<strong>${this.policyEnabledText}</strong>`, - }, - false, - ); + ...mapState(['formOptions', 'isLoading']), + ...mapGetters({ isEdited: 'getIsEdited' }), + ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + isSubmitButtonDisabled() { + return !this.formIsValid || this.isLoading; }, - regexHelpText() { - return sprintf( - s__( - 'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', - ), - { - codeStart: '<code>', - codeEnd: '</code>', - }, - false, - ); - }, - nameRegexPlaceholder() { - return '.*'; - }, - nameRegexState() { - return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null; - }, - formIsInvalid() { - return this.nameRegexState === false; + isCancelButtonDisabled() { + return !this.isEdited || this.isLoading; }, }, methods: { ...mapActions(['resetSettings', 'saveSettings']), + reset() { + this.track('reset_form'); + this.resetSettings(); + }, + submit() { + this.track('submit_form'); + this.saveSettings() + .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) + .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' })); + }, }, }; </script> <template> - <form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings"> + <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> <gl-card> <template #header> {{ s__('ContainerRegistry|Tag expiration policy') }} </template> - <template> - <gl-form-group - id="expiration-policy-toggle-group" - :label-cols="$options.labelsConfig.cols" - :label-align="$options.labelsConfig.align" - label-for="expiration-policy-toggle" - :label="s__('ContainerRegistry|Expiration policy:')" - > - <div class="d-flex align-items-start"> - <gl-toggle id="expiration-policy-toggle" v-model="enabled" /> - <span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span> - </div> - </gl-form-group> - - <gl-form-group - id="expiration-policy-interval-group" - :label-cols="$options.labelsConfig.cols" - :label-align="$options.labelsConfig.align" - label-for="expiration-policy-interval" - :label="s__('ContainerRegistry|Expiration interval:')" - > - <gl-form-select id="expiration-policy-interval" v-model="older_than" :disabled="!enabled"> - <option v-for="option in formOptions.olderThan" :key="option.key" :value="option.key"> - {{ option.label }} - </option> - </gl-form-select> - </gl-form-group> - - <gl-form-group - id="expiration-policy-schedule-group" - :label-cols="$options.labelsConfig.cols" - :label-align="$options.labelsConfig.align" - label-for="expiration-policy-schedule" - :label="s__('ContainerRegistry|Expiration schedule:')" - > - <gl-form-select id="expiration-policy-schedule" v-model="cadence" :disabled="!enabled"> - <option v-for="option in formOptions.cadence" :key="option.key" :value="option.key"> - {{ option.label }} - </option> - </gl-form-select> - </gl-form-group> - - <gl-form-group - id="expiration-policy-latest-group" - :label-cols="$options.labelsConfig.cols" - :label-align="$options.labelsConfig.align" - label-for="expiration-policy-latest" - :label="s__('ContainerRegistry|Number of tags to retain:')" - > - <gl-form-select id="expiration-policy-latest" v-model="keep_n" :disabled="!enabled"> - <option v-for="option in formOptions.keepN" :key="option.key" :value="option.key"> - {{ option.label }} - </option> - </gl-form-select> - </gl-form-group> - - <gl-form-group - id="expiration-policy-name-matching-group" - :label-cols="$options.labelsConfig.cols" - :label-align="$options.labelsConfig.align" - label-for="expiration-policy-name-matching" - :label="s__('ContainerRegistry|Expire Docker tags that match this regex:')" - :state="nameRegexState" - :invalid-feedback=" - s__('ContainerRegistry|The value of this input should be less than 255 characters') - " - > - <gl-form-textarea - id="expiration-policy-name-matching" - v-model="name_regex" - :placeholder="nameRegexPlaceholder" - :state="nameRegexState" - :disabled="!enabled" - trim - /> - <template #description> - <span ref="regex-description" v-html="regexHelpText"></span> - </template> - </gl-form-group> + <template #default> + <expiration-policy-fields + v-model="settings" + :form-options="formOptions" + :is-loading="isLoading" + @validated="formIsValid = true" + @invalidated="formIsValid = false" + /> </template> <template #footer> <div class="d-flex justify-content-end"> - <gl-button ref="cancel-button" type="reset" class="mr-2 d-block">{{ - __('Cancel') - }}</gl-button> + <gl-button + ref="cancel-button" + type="reset" + class="mr-2 d-block" + :disabled="isCancelButtonDisabled" + > + {{ __('Cancel') }} + </gl-button> <gl-button ref="save-button" type="submit" - :disabled="formIsInvalid" + :disabled="isSubmitButtonDisabled" variant="success" - class="d-block" + class="d-flex justify-content-center align-items-center js-no-auto-disable" > {{ __('Save expiration policy') }} + <gl-loading-icon v-if="isLoading" class="ml-2" /> </gl-button> </div> </template> diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index 927b6059884..6ae1dbb72c4 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,8 +1,10 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; import store from './store/'; import RegistrySettingsApp from './components/registry_settings_app.vue'; +Vue.use(GlToast); Vue.use(Translate); export default () => { diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js index 5e46d564121..d0379d05164 100644 --- a/app/assets/javascripts/registry/settings/store/actions.js +++ b/app/assets/javascripts/registry/settings/store/actions.js @@ -1,18 +1,16 @@ import Api from '~/api'; -import createFlash from '~/flash'; -import { - FETCH_SETTINGS_ERROR_MESSAGE, - UPDATE_SETTINGS_ERROR_MESSAGE, - UPDATE_SETTINGS_SUCCESS_MESSAGE, -} from '../constants'; import * as types from './mutation_types'; export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); -export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data); -export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE); -export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE); +export const receiveSettingsSuccess = ({ commit }, data) => { + if (data) { + commit(types.SET_SETTINGS, data); + } else { + commit(types.SET_IS_DISABLED, true); + } +}; export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); export const fetchSettings = ({ dispatch, state }) => { @@ -21,7 +19,6 @@ export const fetchSettings = ({ dispatch, state }) => { .then(({ data: { container_expiration_policy } }) => dispatch('receiveSettingsSuccess', container_expiration_policy), ) - .catch(() => dispatch('receiveSettingsError')) .finally(() => dispatch('toggleLoading')); }; @@ -30,11 +27,9 @@ export const saveSettings = ({ dispatch, state }) => { return Api.updateProject(state.projectId, { container_expiration_policy_attributes: state.settings, }) - .then(({ data: { container_expiration_policy } }) => { - dispatch('receiveSettingsSuccess', container_expiration_policy); - createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success'); - }) - .catch(() => dispatch('updateSettingsError')) + .then(({ data: { container_expiration_policy } }) => + dispatch('receiveSettingsSuccess', container_expiration_policy), + ) .finally(() => dispatch('toggleLoading')); }; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js index fc32a9f08e4..639becebeec 100644 --- a/app/assets/javascripts/registry/settings/store/getters.js +++ b/app/assets/javascripts/registry/settings/store/getters.js @@ -1,8 +1,21 @@ -import { findDefaultOption } from '../utils'; +import { isEqual } from 'lodash'; +import { findDefaultOption } from '../../shared/utils'; export const getCadence = state => state.settings.cadence || findDefaultOption(state.formOptions.cadence); + export const getKeepN = state => state.settings.keep_n || findDefaultOption(state.formOptions.keepN); + export const getOlderThan = state => state.settings.older_than || findDefaultOption(state.formOptions.olderThan); + +export const getSettings = (state, getters) => ({ + enabled: state.settings.enabled, + cadence: getters.getCadence, + older_than: getters.getOlderThan, + keep_n: getters.getKeepN, + name_regex: state.settings.name_regex, +}); + +export const getIsEdited = state => !isEqual(state.original, state.settings); diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js index db499ffa761..2d071567c1f 100644 --- a/app/assets/javascripts/registry/settings/store/mutation_types.js +++ b/app/assets/javascripts/registry/settings/store/mutation_types.js @@ -3,3 +3,4 @@ export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; export const SET_SETTINGS = 'SET_SETTINGS'; export const RESET_SETTINGS = 'RESET_SETTINGS'; +export const SET_IS_DISABLED = 'SET_IS_DISABLED'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js index 25a67cc6973..f562137db1a 100644 --- a/app/assets/javascripts/registry/settings/store/mutations.js +++ b/app/assets/javascripts/registry/settings/store/mutations.js @@ -9,13 +9,16 @@ export default { olderThan: JSON.parse(initialState.olderThanOptions), }; }, - [types.UPDATE_SETTINGS](state, settings) { - state.settings = { ...state.settings, ...settings }; + [types.UPDATE_SETTINGS](state, data) { + state.settings = { ...state.settings, ...data.settings }; }, [types.SET_SETTINGS](state, settings) { state.settings = settings; state.original = Object.freeze(settings); }, + [types.SET_IS_DISABLED](state, isDisabled) { + state.isDisabled = isDisabled; + }, [types.RESET_SETTINGS](state) { state.settings = { ...state.original }; }, diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js index 50c882e1839..582e18e5465 100644 --- a/app/assets/javascripts/registry/settings/store/state.js +++ b/app/assets/javascripts/registry/settings/store/state.js @@ -8,6 +8,10 @@ export default () => ({ */ isLoading: false, /* + * Boolean to determine if the user is allowed to interact with the form + */ + isDisabled: false, + /* * This contains the data shown and manipulated in the UI * Has the following structure: * { diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/registry/settings/utils.js deleted file mode 100644 index 75af401e96d..00000000000 --- a/app/assets/javascripts/registry/settings/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -export const findDefaultOption = options => { - const item = options.find(o => o.default); - return item ? item.key : null; -}; - -export default () => {}; diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue new file mode 100644 index 00000000000..3e212f09e35 --- /dev/null +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -0,0 +1,197 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { NAME_REGEX_LENGTH } from '../constants'; +import { mapComputedToEvent } from '../utils'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlFormSelect, + GlFormTextarea, + GlSprintf, + }, + props: { + formOptions: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: Object, + required: false, + default: () => ({}), + }, + labelCols: { + type: [Number, String], + required: false, + default: 3, + }, + labelAlign: { + type: String, + required: false, + default: 'right', + }, + }, + nameRegexPlaceholder: '.*', + selectList: [ + { + name: 'expiration-policy-interval', + label: s__('ContainerRegistry|Expiration interval:'), + model: 'older_than', + optionKey: 'olderThan', + }, + { + name: 'expiration-policy-schedule', + label: s__('ContainerRegistry|Expiration schedule:'), + model: 'cadence', + optionKey: 'cadence', + }, + { + name: 'expiration-policy-latest', + label: s__('ContainerRegistry|Number of tags to retain:'), + model: 'keep_n', + optionKey: 'keepN', + }, + ], + data() { + return { + uniqueId: uniqueId(), + }; + }, + computed: { + ...mapComputedToEvent(['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex'], 'value'), + policyEnabledText() { + return this.enabled ? __('enabled') : __('disabled'); + }, + nameRegexState() { + return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null; + }, + fieldsValidity() { + return this.nameRegexState !== false; + }, + isFormElementDisabled() { + return !this.enabled || this.isLoading; + }, + }, + watch: { + fieldsValidity: { + immediate: true, + handler(valid) { + if (valid) { + this.$emit('validated'); + } else { + this.$emit('invalidated'); + } + }, + }, + }, + methods: { + idGenerator(id) { + return `${id}_${this.uniqueId}`; + }, + updateModel(value, key) { + this[key] = value; + }, + }, +}; +</script> + +<template> + <div ref="form-elements" class="lh-2"> + <gl-form-group + :id="idGenerator('expiration-policy-toggle-group')" + :label-cols="labelCols" + :label-align="labelAlign" + :label-for="idGenerator('expiration-policy-toggle')" + :label="s__('ContainerRegistry|Expiration policy:')" + > + <div class="d-flex align-items-start"> + <gl-toggle + :id="idGenerator('expiration-policy-toggle')" + v-model="enabled" + :disabled="isLoading" + /> + <span class="mb-2 ml-1 lh-2"> + <gl-sprintf + :message="s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}')" + > + <template #toggleStatus> + <strong>{{ policyEnabledText }}</strong> + </template> + </gl-sprintf> + </span> + </div> + </gl-form-group> + + <gl-form-group + v-for="select in $options.selectList" + :id="idGenerator(`${select.name}-group`)" + :key="select.name" + :label-cols="labelCols" + :label-align="labelAlign" + :label-for="idGenerator(select.name)" + :label="select.label" + > + <gl-form-select + :id="idGenerator(select.name)" + :value="value[select.model]" + :disabled="isFormElementDisabled" + @input="updateModel($event, select.model)" + > + <option + v-for="option in formOptions[select.optionKey]" + :key="option.key" + :value="option.key" + > + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + :id="idGenerator('expiration-policy-name-matching-group')" + :label-cols="labelCols" + :label-align="labelAlign" + :label-for="idGenerator('expiration-policy-name-matching')" + :label=" + s__('ContainerRegistry|Docker tags with names matching this regex pattern will expire:') + " + :state="nameRegexState" + :invalid-feedback=" + s__('ContainerRegistry|The value of this input should be less than 255 characters') + " + > + <gl-form-textarea + :id="idGenerator('expiration-policy-name-matching')" + v-model="name_regex" + :placeholder="$options.nameRegexPlaceholder" + :state="nameRegexState" + :disabled="isFormElementDisabled" + trim + /> + <template #description> + <span ref="regex-description"> + <gl-sprintf + :message=" + s__( + 'ContainerRegistry|Wildcards such as %{codeStart}.*-stable%{codeEnd} or %{codeStart}production/.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + ) + " + > + <template #code="{content}"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </span> + </template> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/shared/constants.js index c0dac466b29..c0dac466b29 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js new file mode 100644 index 00000000000..d85a3ad28c2 --- /dev/null +++ b/app/assets/javascripts/registry/shared/utils.js @@ -0,0 +1,19 @@ +export const findDefaultOption = options => { + const item = options.find(o => o.default); + return item ? item.key : null; +}; + +export const mapComputedToEvent = (list, root) => { + const result = {}; + list.forEach(e => { + result[e] = { + get() { + return this[root][e]; + }, + set(value) { + this.$emit('input', { ...this[root], [e]: value }); + }, + }; + }); + return result; +}; diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/components/app_edit.vue index 073cfcd7694..bdc2b3abb8c 100644 --- a/app/assets/javascripts/releases/detail/components/app.vue +++ b/app/assets/javascripts/releases/components/app_edit.vue @@ -7,7 +7,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; export default { - name: 'ReleaseDetailApp', + name: 'ReleaseEditApp', components: { GlFormInput, GlFormGroup, @@ -18,7 +18,7 @@ export default { autofocusonshow, }, computed: { - ...mapState([ + ...mapState('detail', [ 'isFetchingRelease', 'fetchError', 'markdownDocsPath', @@ -42,7 +42,7 @@ export default { ); }, tagName() { - return this.$store.state.release.tagName; + return this.$store.state.detail.release.tagName; }, tagNameHintText() { return sprintf( @@ -60,7 +60,7 @@ export default { }, releaseTitle: { get() { - return this.$store.state.release.name; + return this.$store.state.detail.release.name; }, set(title) { this.updateReleaseTitle(title); @@ -68,7 +68,7 @@ export default { }, releaseNotes: { get() { - return this.$store.state.release.description; + return this.$store.state.detail.release.description; }, set(notes) { this.updateReleaseNotes(notes); @@ -79,7 +79,7 @@ export default { this.fetchRelease(); }, methods: { - ...mapActions([ + ...mapActions('detail', [ 'fetchRelease', 'updateRelease', 'updateReleaseTitle', diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/components/app_index.vue index eb63e709ebd..f602c9fdda2 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -32,7 +32,7 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']), + ...mapState('list', ['isLoading', 'releases', 'hasError', 'pageInfo']), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; }, @@ -47,7 +47,7 @@ export default { }); }, methods: { - ...mapActions(['fetchReleases']), + ...mapActions('list', ['fetchReleases']), onChangePage(page) { historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); this.fetchReleases({ page, projectId: this.projectId }); diff --git a/app/assets/javascripts/releases/list/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index d9abd195fee..d9abd195fee 100644 --- a/app/assets/javascripts/releases/list/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index d924b5795f0..e6bb5325120 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,9 +1,11 @@ <script> import _ from 'underscore'; +import $ from 'jquery'; import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import '~/behaviors/markdown/render_gfm'; import EvidenceBlock from './evidence_block.vue'; import ReleaseBlockAssets from './release_block_assets.vue'; import ReleaseBlockFooter from './release_block_footer.vue'; @@ -65,7 +67,10 @@ export default { return Boolean(this.glFeatures.releaseIssueSummary && !_.isEmpty(this.release.milestones)); }, }, + mounted() { + this.renderGFM(); + const hash = getLocationHash(); if (hash && slugify(hash) === this.id) { this.isHighlighted = true; @@ -76,6 +81,11 @@ export default { scrollToElement(this.$el); } }, + methods: { + renderGFM() { + $(this.$refs['gfm-content']).renderGFM(); + }, + }, }; </script> <template> @@ -91,7 +101,7 @@ export default { <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> - <div class="card-text prepend-top-default"> + <div ref="gfm-content" class="card-text prepend-top-default"> <div v-html="release.description_html"></div> </div> </div> diff --git a/app/assets/javascripts/releases/list/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index e840bc90d68..06b7f97a8de 100644 --- a/app/assets/javascripts/releases/list/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -52,7 +52,7 @@ export default { > <icon name="doc-code" class="align-top append-right-4" /> {{ __('Source code') }} - <icon name="arrow-down" /> + <icon name="chevron-down" /> </button> <div class="js-sources-dropdown dropdown-menu"> diff --git a/app/assets/javascripts/releases/list/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue index ff6b00d8221..e7075d4d67a 100644 --- a/app/assets/javascripts/releases/list/components/release_block_author.vue +++ b/app/assets/javascripts/releases/components/release_block_author.vue @@ -27,7 +27,7 @@ export default { <template> <div class="d-flex"> - <gl-sprintf message="by %{user}"> + <gl-sprintf :message="__('by %{user}')"> <template #user> <user-avatar-link class="prepend-left-4" diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index 8533fc17ffd..8533fc17ffd 100644 --- a/app/assets/javascripts/releases/list/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue diff --git a/app/assets/javascripts/releases/list/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 9c5dcf2a709..b459418aef2 100644 --- a/app/assets/javascripts/releases/list/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -19,8 +19,11 @@ export default { }, }, computed: { - shouldShowEditButton() { - return Boolean(this.release._links && this.release._links.edit_url); + editLink() { + return this.release._links?.edit_url; + }, + selfLink() { + return this.release._links?.self; }, }, }; @@ -29,17 +32,20 @@ export default { <template> <div class="card-header d-flex align-items-center bg-white pr-0"> <h2 class="card-title my-2 mr-auto gl-font-size-20"> - {{ release.name }} + <gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit"> + {{ release.name }} + </gl-link> + <template v-else>{{ release.name }}</template> <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ __('Upcoming Release') }}</gl-badge> </h2> <gl-link - v-if="shouldShowEditButton" + v-if="editLink" v-gl-tooltip class="btn btn-default append-right-10 js-edit-button ml-2" :title="__('Edit this release')" - :href="release._links.edit_url" + :href="editLink" > <icon name="pencil" /> </gl-link> diff --git a/app/assets/javascripts/releases/list/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue index f0aad594062..f0aad594062 100644 --- a/app/assets/javascripts/releases/list/components/release_block_metadata.vue +++ b/app/assets/javascripts/releases/components/release_block_metadata.vue diff --git a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index d3e354d6157..d3e354d6157 100644 --- a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue diff --git a/app/assets/javascripts/releases/list/components/release_block_milestones.vue b/app/assets/javascripts/releases/components/release_block_milestones.vue index a3dff75b828..a3dff75b828 100644 --- a/app/assets/javascripts/releases/list/components/release_block_milestones.vue +++ b/app/assets/javascripts/releases/components/release_block_milestones.vue diff --git a/app/assets/javascripts/releases/list/constants.js b/app/assets/javascripts/releases/constants.js index defcd917465..defcd917465 100644 --- a/app/assets/javascripts/releases/list/constants.js +++ b/app/assets/javascripts/releases/constants.js diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js deleted file mode 100644 index 0dab90a1ede..00000000000 --- a/app/assets/javascripts/releases/detail/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; -import ReleaseDetailApp from './components/app.vue'; -import createStore from './store'; - -export default () => { - const el = document.getElementById('js-edit-release-page'); - - const store = createStore(); - store.dispatch('setInitialState', el.dataset); - - return new Vue({ - el, - store, - components: { ReleaseDetailApp }, - render(createElement) { - return createElement('release-detail-app'); - }, - }); -}; diff --git a/app/assets/javascripts/releases/list/index.js b/app/assets/javascripts/releases/list/index.js deleted file mode 100644 index adbed3cb8e2..00000000000 --- a/app/assets/javascripts/releases/list/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import App from './components/app.vue'; -import createStore from './store'; - -export default () => { - const element = document.getElementById('js-releases-page'); - - return new Vue({ - el: element, - store: createStore(), - components: { - App, - }, - render(createElement) { - return createElement('app', { - props: { - projectId: element.dataset.projectId, - documentationLink: element.dataset.documentationPath, - illustrationPath: element.dataset.illustrationPath, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/releases/list/store/index.js b/app/assets/javascripts/releases/list/store/index.js deleted file mode 100644 index 968b94f0e0d..00000000000 --- a/app/assets/javascripts/releases/list/store/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import state from './state'; -import * as actions from './actions'; -import mutations from './mutations'; - -Vue.use(Vuex); - -export default () => - new Vuex.Store({ - actions, - mutations, - state: state(), - }); diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js new file mode 100644 index 00000000000..2bc2728312a --- /dev/null +++ b/app/assets/javascripts/releases/mount_edit.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import ReleaseEditApp from './components/app_edit.vue'; +import createStore from './stores'; +import detailModule from './stores/modules/detail'; + +export default () => { + const el = document.getElementById('js-edit-release-page'); + + const store = createStore({ detail: detailModule }); + store.dispatch('detail/setInitialState', el.dataset); + + return new Vue({ + el, + store, + render: h => h(ReleaseEditApp), + }); +}; diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js new file mode 100644 index 00000000000..6fcb6d802e4 --- /dev/null +++ b/app/assets/javascripts/releases/mount_index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import ReleaseListApp from './components/app_index.vue'; +import createStore from './stores'; +import listModule from './stores/modules/list'; + +export default () => { + const el = document.getElementById('js-releases-page'); + + return new Vue({ + el, + store: createStore({ list: listModule }), + render: h => + h(ReleaseListApp, { + props: { + projectId: el.dataset.projectId, + documentationLink: el.dataset.documentationPath, + illustrationPath: el.dataset.illustrationPath, + }, + }), + }); +}; diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js new file mode 100644 index 00000000000..aa607906a0e --- /dev/null +++ b/app/assets/javascripts/releases/stores/index.js @@ -0,0 +1,6 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; + +Vue.use(Vuex); + +export default modules => new Vuex.Store({ modules }); diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index c9749582f5c..c9749582f5c 100644 --- a/app/assets/javascripts/releases/detail/store/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/detail/index.js new file mode 100644 index 00000000000..243c2389d8c --- /dev/null +++ b/app/assets/javascripts/releases/stores/modules/detail/index.js @@ -0,0 +1,10 @@ +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +export default { + namespaced: true, + actions, + mutations, + state, +}; diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index 75e1d78a645..75e1d78a645 100644 --- a/app/assets/javascripts/releases/detail/store/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index d739978d755..d739978d755 100644 --- a/app/assets/javascripts/releases/detail/store/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 7e3d975f1ae..7e3d975f1ae 100644 --- a/app/assets/javascripts/releases/detail/store/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index b15fb69226f..b15fb69226f 100644 --- a/app/assets/javascripts/releases/list/store/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js diff --git a/app/assets/javascripts/releases/stores/modules/list/index.js b/app/assets/javascripts/releases/stores/modules/list/index.js new file mode 100644 index 00000000000..e4633b15a0c --- /dev/null +++ b/app/assets/javascripts/releases/stores/modules/list/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + mutations, + state, +}; diff --git a/app/assets/javascripts/releases/list/store/mutation_types.js b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js index a74bf15c515..a74bf15c515 100644 --- a/app/assets/javascripts/releases/list/store/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js index 99fc096264a..99fc096264a 100644 --- a/app/assets/javascripts/releases/list/store/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js index c251f56c9c5..c251f56c9c5 100644 --- a/app/assets/javascripts/releases/list/store/state.js +++ b/app/assets/javascripts/releases/stores/modules/list/state.js diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 82601363aa4..88d174f96ed 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -62,9 +62,21 @@ export default { return ( report.existing_failures.length > 0 || report.new_failures.length > 0 || - report.resolved_failures.length > 0 + report.resolved_failures.length > 0 || + report.existing_errors.length > 0 || + report.new_errors.length > 0 || + report.resolved_errors.length > 0 ); }, + unresolvedIssues(report) { + return report.existing_failures.concat(report.existing_errors); + }, + newIssues(report) { + return report.new_failures.concat(report.new_errors); + }, + resolvedIssues(report) { + return report.resolved_failures.concat(report.resolved_errors); + }, }, }; </script> @@ -87,9 +99,9 @@ export default { <issues-list v-if="shouldRenderIssuesList(report)" :key="`issues-list-${i}`" - :unresolved-issues="report.existing_failures" - :new-issues="report.new_failures" - :resolved-issues="report.resolved_failures" + :unresolved-issues="unresolvedIssues(report)" + :new-issues="newIssues(report)" + :resolved-issues="resolvedIssues(report)" :component="$options.componentNames.TestIssueBody" class="report-block-group-list" /> diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 40ce200befb..78c355ecb76 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -46,8 +46,8 @@ export default { </a> </template> - <template v-else-if="field.type === $options.fieldTypes.miliseconds">{{ - sprintf(__('%{value} ms'), { value: field.value }) + <template v-else-if="field.type === $options.fieldTypes.seconds">{{ + sprintf(__('%{value} s'), { value: field.value }) }}</template> <template v-else-if="field.type === $options.fieldTypes.text"> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 66ac1af062b..1845b51e6b2 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -1,7 +1,7 @@ export const fieldTypes = { codeBock: 'codeBlock', link: 'link', - miliseconds: 'miliseconds', + seconds: 'seconds', text: 'text', }; diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 2a37f5b74fa..68f6de3a7ee 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -16,6 +16,7 @@ export default { state.summary.total = response.summary.total; state.summary.resolved = response.summary.resolved; state.summary.failed = response.summary.failed; + state.summary.errored = response.summary.errored; state.status = response.status; state.reports = response.suites; @@ -29,6 +30,7 @@ export default { total: 0, resolved: 0, failed: 0, + errored: 0, }; state.status = null; }, diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js index 25f9f70d095..4f9eb53e787 100644 --- a/app/assets/javascripts/reports/store/state.js +++ b/app/assets/javascripts/reports/store/state.js @@ -13,6 +13,7 @@ export default () => ({ total: 0, resolved: 0, failed: 0, + errored: 0, }, /** @@ -23,10 +24,14 @@ export default () => ({ * total: {Number}, * resolved: {Number}, * failed: {Number}, + * errored: {Number}, * }, * new_failures: {Array.<Object>}, * resolved_failures: {Array.<Object>}, * existing_failures: {Array.<Object>}, + * new_errors: {Array.<Object>}, + * resolved_errors: {Array.<Object>}, + * existing_errors: {Array.<Object>}, * } */ reports: [], @@ -48,7 +53,7 @@ export default () => ({ execution_time: { value: null, text: s__('Reports|Execution time'), - type: fieldTypes.miliseconds, + type: fieldTypes.seconds, }, failure: { value: null, diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js index 7381f038eaf..ce3ffaae703 100644 --- a/app/assets/javascripts/reports/store/utils.js +++ b/app/assets/javascripts/reports/store/utils.js @@ -8,10 +8,11 @@ import { } from '../constants'; const textBuilder = results => { - const { failed, resolved, total } = results; + const { failed, errored, resolved, total } = results; - const failedString = failed - ? n__('%d failed/error test result', '%d failed/error test results', failed) + const failedOrErrored = (failed || 0) + (errored || 0); + const failedString = failedOrErrored + ? n__('%d failed/error test result', '%d failed/error test results', failedOrErrored) : null; const resolvedString = resolved ? n__('%d fixed test result', '%d fixed test results', resolved) @@ -20,7 +21,7 @@ const textBuilder = results => { let resultsString = s__('Reports|no changed test results'); - if (failed) { + if (failedOrErrored) { if (resolved) { resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), { failedString, diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index f6b9ea5d30d..751565ad542 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -34,7 +34,10 @@ export default { projectPath: this.projectPath, }; }, - update: data => data.project.userPermissions, + update: data => data.project?.userPermissions, + error(error) { + throw error; + }, }, }, mixins: [getRefMixin], @@ -42,7 +45,7 @@ export default { currentPath: { type: String, required: false, - default: '/', + default: '', }, canCollaborate: { type: Boolean, @@ -104,10 +107,16 @@ export default { return acc.concat({ name, path, - to: `/tree/${this.ref}${path}`, + to: `/-/tree/${escape(this.ref)}${escape(path)}`, }); }, - [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }], + [ + { + name: this.projectShortPath, + path: '/', + to: `/-/tree/${escape(this.ref)}/`, + }, + ], ); }, canCreateMrFromFork() { @@ -124,7 +133,7 @@ export default { }, { attrs: { - href: `${this.newBlobPath}${this.currentPath}`, + href: `${this.newBlobPath}/${this.currentPath ? escape(this.currentPath) : ''}`, class: 'qa-new-file-option', }, text: __('New file'), @@ -172,7 +181,7 @@ export default { ); } - if (this.userPermissions.pushCode) { + if (this.userPermissions?.pushCode) { items.push( { type: ROW_TYPES.divider, @@ -233,7 +242,7 @@ export default { <template slot="button-content"> <span class="sr-only">{{ __('Add to tree') }}</span> <icon name="plus" :size="16" class="float-left" /> - <icon name="arrow-down" :size="16" class="float-left" /> + <icon name="chevron-down" :size="16" class="float-left" /> </template> <template v-for="(item, i) in dropdownItems"> <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs"> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index fe1724acf89..968bd9af84f 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -40,16 +40,19 @@ export default { }; }, update: data => { - const pipelines = data.project.repository.tree.lastCommit.pipelines.edges; + const pipelines = data.project?.repository?.tree?.lastCommit?.pipelines?.edges; return { - ...data.project.repository.tree.lastCommit, - pipeline: pipelines.length && pipelines[0].node, + ...data.project?.repository?.tree?.lastCommit, + pipeline: pipelines?.length && pipelines[0].node, }; }, context: { isSingleRequest: true, }, + error(error) { + throw error; + }, }, }, props: { @@ -62,7 +65,7 @@ export default { data() { return { projectPath: '', - commit: {}, + commit: null, showDescription: false, }; }, @@ -79,6 +82,11 @@ export default { return this.commit.sha.substr(0, 8); }, }, + watch: { + currentPath() { + this.commit = null; + }, + }, methods: { toggleShowDescription() { this.showDescription = !this.showDescription; @@ -91,7 +99,7 @@ export default { <template> <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> - <template v-else> + <template v-else-if="commit"> <user-avatar-link v-if="commit.author" :link-href="commit.author.webUrl" @@ -100,7 +108,12 @@ export default { class="avatar-cell" /> <span v-else class="avatar-cell user-avatar-link"> - <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" /> + <img + :src="commit.authorGravatar || $options.defaultAvatarUrl" + width="40" + height="40" + class="avatar s40" + /> </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> @@ -138,9 +151,8 @@ export default { v-if="commit.description" :class="{ 'd-block': showDescription }" class="commit-row-description append-bottom-8" + >{{ commit.description }}</pre > - {{ commit.description }} - </pre> </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 29a3340b83d..2ba170998e8 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -71,7 +71,12 @@ export default { <template> <div class="tree-content-holder"> <div class="table-holder bordered-box"> - <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> + <table + :aria-label="tableCaption" + class="table tree-table" + aria-live="polite" + data-qa-selector="file_tree_table" + > <table-header v-once /> <tbody> <parent-row diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 70a188f98cc..a5c6c9822fb 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -28,7 +28,7 @@ export default { return splitArray.join('/'); }, parentRoute() { - return { path: `/tree/${this.commitRef}/${this.parentPath}` }; + return { path: `/-/tree/${escape(this.commitRef)}/${escape(this.parentPath)}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index a8e13241c37..c905c39bbba 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,4 +1,5 @@ <script> +import { escapeRegExp } from 'lodash'; import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -90,7 +91,7 @@ export default { }, computed: { routerLinkTo() { - return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null; + return this.isFolder ? { path: `/-/tree/${escape(this.ref)}/${escape(this.path)}` } : null; }, iconName() { return `fa-${getIconName(this.type, this.path)}`; @@ -105,7 +106,7 @@ export default { return this.isFolder ? 'router-link' : 'a'; }, fullPath() { - return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); + return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), ''); }, shortSha() { return this.sha.slice(0, 8); @@ -138,7 +139,13 @@ export default { class="d-inline-block align-text-bottom fa-fw" /> <i v-else :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> - <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> + <component + :is="linkComponent" + :to="routerLinkTo" + :href="url" + class="str-truncated" + data-qa-selector="file_name_link" + > {{ fullPath }} </component> <!-- eslint-disable-next-line @gitlab/vue-i18n/no-bare-strings --> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 92e33b013c3..7b34e9ef60d 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -86,7 +86,8 @@ export default { }, }) .then(({ data }) => { - if (!data) return; + if (data.errors) throw data.errors; + if (!data?.project?.repository) return; const pageInfo = this.hasNextPage(data.project.repository.tree); @@ -99,12 +100,15 @@ export default { {}, ); - if (pageInfo && pageInfo.hasNextPage) { + if (pageInfo?.hasNextPage) { this.nextPageCursor = pageInfo.endCursor; this.fetchFiles(); } }) - .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + .catch(error => { + createFlash(__('An error occurred while fetching folder content.')); + throw error; + }); }, normalizeData(key, data) { return this.entries[key].concat(data.map(({ node }) => node)); diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6936c08d852..265df20636b 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -48,7 +48,7 @@ const defaultClient = createDefaultClient( case 'TreeEntry': case 'Submodule': case 'Blob': - return `${obj.flatPath}-${obj.id}`; + return `${escape(obj.flatPath)}-${obj.id}`; default: // If the type doesn't match any of the above we fallback // to using the default Apollo ID diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 2ef0c078f13..637060f6ed9 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -23,13 +23,13 @@ export default function setupVueRepositoryList() { projectPath, projectShortPath, ref, - vueFileListLfsBadge: gon?.features?.vueFileListLfsBadge, + vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false, commits: [], }, }); - router.afterEach(({ params: { pathMatch } }) => { - setTitle(pathMatch, ref, fullName); + router.afterEach(({ params: { path } }) => { + setTitle(path, ref, fullName); }); const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); @@ -48,9 +48,9 @@ export default function setupVueRepositoryList() { newDirPath, } = breadcrumbEl.dataset; - router.afterEach(({ params: { pathMatch = '/' } }) => { - updateFormAction('.js-upload-blob-form', uploadPath, pathMatch); - updateFormAction('.js-create-dir-form', newDirPath, pathMatch); + router.afterEach(({ params: { path = '/' } }) => { + updateFormAction('.js-upload-blob-form', uploadPath, path); + updateFormAction('.js-create-dir-form', newDirPath, path); }); // eslint-disable-next-line no-new @@ -61,7 +61,7 @@ export default function setupVueRepositoryList() { render(h) { return h(Breadcrumbs, { props: { - currentPath: this.$route.params.pathMatch, + currentPath: this.$route.params.path, canCollaborate: parseBoolean(canCollaborate), canEditTree: parseBoolean(canEditTree), newBranchPath, @@ -84,7 +84,7 @@ export default function setupVueRepositoryList() { render(h) { return h(LastCommit, { props: { - currentPath: this.$route.params.pathMatch, + currentPath: this.$route.params.path, }, }); }, @@ -100,7 +100,7 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: historyLink + (this.$route.params.pathMatch || '/'), + path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`, text: __('History'), }, }); @@ -117,7 +117,7 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`), + path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`), text: __('Web IDE'), cssClass: 'qa-web-ide-button', }, @@ -134,7 +134,7 @@ export default function setupVueRepositoryList() { el: directoryDownloadLinks, router, render(h) { - const currentPath = this.$route.params.pathMatch || '/'; + const currentPath = this.$route.params.path || '/'; if (currentPath !== '/') { return h(DirectoryDownloadLinks, { diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 6498725adb6..192e410b36f 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -27,7 +27,9 @@ export function fetchLogsTree(client, path, offset, resolver = null) { fetchpromise = axios .get( - `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`, + `${gon.relative_url_root}/${projectPath}/-/refs/${escape(ref)}/logs_tree/${escape( + path.replace(/^\//, ''), + )}`, { params: { format: 'json', offset }, }, diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index e68996245a8..cb6c2294679 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -13,10 +13,10 @@ export default { return { projectPath: '', loadingPath: null }; }, beforeRouteUpdate(to, from, next) { - this.preload(to.params.pathMatch, next); + this.preload(to.params.path, next); }, methods: { - preload(path, next) { + preload(path = '/', next) { this.loadingPath = path.replace(/^\//, ''); return this.$apollo diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index c812614e94d..a22cadf0e8d 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -10,6 +10,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { webUrl authoredDate authorName + authorGravatar author { name avatarUrl diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index ebf0a7091ea..2386773699c 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,11 +12,11 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `/tree/${baseRef}(/.*)?`, + path: `(/-)?/tree/${escape(baseRef)}/:path*`, name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'), + path: route.params.path?.replace(/^\//, '') || '/', }), }, { diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js index 81565a00d82..abf726194ac 100644 --- a/app/assets/javascripts/repository/utils/dom.js +++ b/app/assets/javascripts/repository/utils/dom.js @@ -1,3 +1,5 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + export const updateElementsVisibility = (selector, isVisible) => { document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible)); }; @@ -6,6 +8,6 @@ export const updateFormAction = (selector, basePath, path) => { const form = document.querySelector(selector); if (form) { - form.action = `${basePath}${path}`; + form.action = joinPaths(basePath, path); } }; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js index ff16fbdd420..9c4b334a1ce 100644 --- a/app/assets/javascripts/repository/utils/title.js +++ b/app/assets/javascripts/repository/utils/title.js @@ -1,5 +1,5 @@ const DEFAULT_TITLE = '· GitLab'; -// eslint-disable-next-line import/prefer-default-export + export const setTitle = (pathMatch, ref, project) => { if (!pathMatch) { document.title = `${project} ${DEFAULT_TITLE}`; @@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => { /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`; }; + +export function updateRefPortionOfTitle(sha, doc = document) { + const { title = '' } = doc; + const titleParts = title.split(' · '); + + if (titleParts.length > 1) { + titleParts[1] = sha; + + /* eslint-disable-next-line no-param-reassign */ + doc.title = titleParts.join(' · '); + } +} diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 2f364eae67f..6b19a72317c 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -106,7 +106,7 @@ export default { saveChangesSelfMonitorProject() { if (this.projectCreated && !this.projectEnabled) { this.showSelfMonitorModal(); - } else { + } else if (!this.projectCreated && !this.loading) { this.createProject(); } }, diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js index 42c94e11989..7db87b4c627 100644 --- a/app/assets/javascripts/self_monitor/index.js +++ b/app/assets/javascripts/self_monitor/index.js @@ -4,15 +4,12 @@ import SelfMonitorForm from './components/self_monitor_form.vue'; export default () => { const el = document.querySelector('.js-self-monitoring-settings'); - let selfMonitorProjectCreated; if (el) { - selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists; // eslint-disable-next-line no-new new Vue({ el, store: store({ - projectEnabled: selfMonitorProjectCreated, ...el.dataset, }), render(createElement) { diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js index f8430a9b136..10deec6921c 100644 --- a/app/assets/javascripts/self_monitor/store/actions.js +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -52,7 +52,7 @@ export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => { }); }; -export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => { +export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorData) => { commit(types.SET_LOADING, false); commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path); commit(types.SET_ALERT_CONTENT, { @@ -62,6 +62,7 @@ export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => { }); commit(types.SET_SHOW_ALERT, true); commit(types.SET_PROJECT_CREATED, true); + dispatch('setSelfMonitor', true); }; export const requestCreateProjectError = ({ commit }, error) => { diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js index b8b4a4af614..a0ce88ff58c 100644 --- a/app/assets/javascripts/self_monitor/store/state.js +++ b/app/assets/javascripts/self_monitor/store/state.js @@ -1,8 +1,8 @@ import { parseBoolean } from '~/lib/utils/common_utils'; export default (initialState = {}) => ({ - projectEnabled: parseBoolean(initialState.projectEnabled) || false, - projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false, + projectEnabled: parseBoolean(initialState.selfMonitoringProjectExists) || false, + projectCreated: parseBoolean(initialState.selfMonitoringProjectExists) || false, createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '', deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '', createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '', diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue index 4d18c5c4bdd..089e0550583 100644 --- a/app/assets/javascripts/serverless/components/environment_row.vue +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -47,7 +47,7 @@ export default { <template> <li :id="envId" :class="isOpenClass" class="group-row has-children"> <div - class="group-row-contents d-flex justify-content-end align-items-center" + class="group-row-contents d-flex justify-content-end align-items-center py-2" role="button" @click.stop="toggleOpen" > diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index d542dad8119..2ac57ac5bcb 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isString } from 'lodash'; import { mapState, mapActions, mapGetters } from 'vuex'; import PodBox from './pod_box.vue'; import Url from './url.vue'; @@ -42,7 +42,7 @@ export default { return this.func.name; }, description() { - return _.isString(this.func.description) ? this.func.description : ''; + return isString(this.func.description) ? this.func.description : ''; }, funcUrl() { return this.func.url; diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 4b3bb078eae..bbafdd7f8f1 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isString } from 'lodash'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Url from './url.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -20,7 +20,7 @@ export default { return this.func.name; }, description() { - if (!_.isString(this.func.description)) { + if (!isString(this.func.description)) { return ''; } @@ -63,7 +63,7 @@ export default { <template> <li :id="name" class="group-row"> - <div class="group-row-contents" role="button" @click="openDetails"> + <div class="group-row-contents py-2" role="button" @click="openDetails"> <p class="float-right text-right"> <span>{{ image }}</span ><br /> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index cdbf57f3e55..e06149f2bcb 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -74,7 +74,7 @@ export default { </script> <template> - <section id="serverless-functions"> + <section id="serverless-functions" class="flex-grow"> <gl-loading-icon v-if="checkingInstalled" :size="2" diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue index 5e30c8d614e..d6de5e56a5c 100644 --- a/app/assets/javascripts/serverless/components/url.vue +++ b/app/assets/javascripts/serverless/components/url.vue @@ -1,12 +1,8 @@ <script> -import { GlButton } from '@gitlab/ui'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - Icon, - GlButton, ClipboardButton, }, props: { @@ -26,13 +22,5 @@ export default { :title="s__('ServerlessURL|Copy URL')" class="input-group-text js-clipboard-btn" /> - <gl-button - :href="uri" - target="_blank" - rel="noopener noreferrer nofollow" - class="input-group-text btn btn-default" - > - <icon name="external-link" /> - </gl-button> </div> </template> diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index de4a7f89449..2d505c4c96b 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -9,7 +9,7 @@ import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import syntaxHighlight from './syntax_highlight'; const WRAPPER = '<div class="diff-content"></div>'; -const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; +const LOADING_HTML = '<span class="spinner"></span>'; const ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>'; const COLLAPSED_HTML = diff --git a/app/assets/javascripts/snippet/collapsible_input.js b/app/assets/javascripts/snippet/collapsible_input.js new file mode 100644 index 00000000000..e7225162f86 --- /dev/null +++ b/app/assets/javascripts/snippet/collapsible_input.js @@ -0,0 +1,45 @@ +const hide = el => el.classList.add('d-none'); +const show = el => el.classList.remove('d-none'); + +const setupCollapsibleInput = el => { + const collapsedEl = el.querySelector('.js-collapsed'); + const expandedEl = el.querySelector('.js-expanded'); + const collapsedInputEl = collapsedEl.querySelector('textarea,input,select'); + const expandedInputEl = expandedEl.querySelector('textarea,input,select'); + const formEl = el.closest('form'); + + const collapse = () => { + hide(expandedEl); + show(collapsedEl); + }; + + const expand = () => { + hide(collapsedEl); + show(expandedEl); + }; + + // NOTE: + // We add focus listener to all form inputs so that we can collapse + // when something is focused that's not the expanded input. + formEl.addEventListener('focusin', e => { + if (e.target === collapsedInputEl) { + expand(); + expandedInputEl.focus(); + } else if (!el.contains(e.target) && !expandedInputEl.value) { + collapse(); + } + }); +}; + +/** + * Usage in HAML + * + * .js-collapsible-input + * .js-collapsed{ class: ('d-none' if is_expanded) } + * = input + * .js-expanded{ class: ('d-none' if !is_expanded) } + * = big_input + */ +export default () => { + Array.from(document.querySelectorAll('.js-collapsible-input')).forEach(setupCollapsibleInput); +}; diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js index dcee17453b8..652531a1289 100644 --- a/app/assets/javascripts/snippet/snippet_bundle.js +++ b/app/assets/javascripts/snippet/snippet_bundle.js @@ -1,6 +1,7 @@ /* global ace */ import $ from 'jquery'; +import setupCollapsibleInputs from './collapsible_input'; export default () => { const editor = ace.edit('editor'); @@ -8,4 +9,6 @@ export default () => { $('.snippet-form-holder form').on('submit', () => { $('.snippet-file-content').val(editor.getValue()); }); + + setupCollapsibleInputs(); }; diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue index 7a2145a800c..e98f56d87f5 100644 --- a/app/assets/javascripts/snippets/components/app.vue +++ b/app/assets/javascripts/snippets/components/app.vue @@ -2,6 +2,7 @@ import GetSnippetQuery from '../queries/snippet.query.graphql'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; +import SnippetBlob from './snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; export default { @@ -9,6 +10,7 @@ export default { SnippetHeader, SnippetTitle, GlLoadingIcon, + SnippetBlob, }, apollo: { snippet: { @@ -50,6 +52,7 @@ export default { <template v-else> <snippet-header :snippet="snippet" /> <snippet-title :snippet="snippet" /> + <snippet-blob :snippet="snippet" /> </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue new file mode 100644 index 00000000000..4703a940e08 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -0,0 +1,97 @@ +<script> +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; +import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; +import BlobHeader from '~/blob/components/blob_header.vue'; +import BlobContent from '~/blob/components/blob_content.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql'; +import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; + +import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; + +export default { + components: { + BlobEmbeddable, + BlobHeader, + BlobContent, + GlLoadingIcon, + }, + apollo: { + blob: { + query: GetSnippetBlobQuery, + variables() { + return { + ids: this.snippet.id, + }; + }, + update: data => data.snippets.edges[0].node.blob, + result(res) { + const viewer = res.data.snippets.edges[0].node.blob.richViewer + ? RICH_BLOB_VIEWER + : SIMPLE_BLOB_VIEWER; + this.switchViewer(viewer, true); + }, + }, + blobContent: { + query: GetBlobContent, + variables() { + return { + ids: this.snippet.id, + rich: this.activeViewerType === RICH_BLOB_VIEWER, + }; + }, + update: data => + data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData, + }, + }, + props: { + snippet: { + type: Object, + required: true, + }, + }, + data() { + return { + blob: {}, + blobContent: '', + activeViewerType: window.location.hash ? SIMPLE_BLOB_VIEWER : '', + }; + }, + computed: { + embeddable() { + return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; + }, + isBlobLoading() { + return this.$apollo.queries.blob.loading; + }, + isContentLoading() { + return this.$apollo.queries.blobContent.loading; + }, + viewer() { + const { richViewer, simpleViewer } = this.blob; + return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer; + }, + }, + methods: { + switchViewer(newViewer, respectHash = false) { + this.activeViewerType = respectHash && window.location.hash ? SIMPLE_BLOB_VIEWER : newViewer; + }, + }, +}; +</script> +<template> + <div> + <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> + <gl-loading-icon + v-if="isBlobLoading" + :label="__('Loading blob')" + size="lg" + class="prepend-top-20 append-bottom-20" + /> + <article v-else class="file-holder snippet-file-content"> + <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer" /> + <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" /> + </article> + </div> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index e8f1bfeaf43..36ba6eeecbd 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -165,7 +165,7 @@ export default { <gl-icon :name="visibilityLevelIcon" :size="14" /> </div> <div class="creator"> - <gl-sprintf message="Authored %{timeago} by %{author}"> + <gl-sprintf :message="__('Authored %{timeago} by %{author}')"> <template #timeago> <time-ago-tooltip :time="snippet.createdAt" @@ -218,7 +218,7 @@ export default { errorMessage }}</gl-alert> - <gl-sprintf message="Are you sure you want to delete %{name}?"> + <gl-sprintf :message="__('Are you sure you want to delete %{name}?')"> <template #name ><strong>{{ snippet.title }}</strong></template > diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue index fc8a9b4a390..6646e70f5db 100644 --- a/app/assets/javascripts/snippets/components/snippet_title.vue +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -25,7 +25,7 @@ export default { </div> <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text"> - <gl-sprintf message="Edited %{timeago}"> + <gl-sprintf :message="__('Edited %{timeago}')"> <template #timeago> <time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" /> </template> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js new file mode 100644 index 00000000000..87e3fe360a3 --- /dev/null +++ b/app/assets/javascripts/snippets/constants.js @@ -0,0 +1,3 @@ +export const SNIPPET_VISIBILITY_PRIVATE = 'private'; +export const SNIPPET_VISIBILITY_INTERNAL = 'internal'; +export const SNIPPET_VISIBILITY_PUBLIC = 'public'; diff --git a/app/assets/javascripts/snippets/fragments/author.fragment.graphql b/app/assets/javascripts/snippets/fragments/author.fragment.graphql deleted file mode 100644 index 2684bd0fa37..00000000000 --- a/app/assets/javascripts/snippets/fragments/author.fragment.graphql +++ /dev/null @@ -1,8 +0,0 @@ -fragment Author on Snippet { - author { - name, - avatarUrl, - username, - webUrl - } -}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql new file mode 100644 index 00000000000..889a88dd93c --- /dev/null +++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql @@ -0,0 +1,13 @@ +query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { + snippets(ids: $ids) { + edges { + node { + id + blob { + richData @include(if: $rich) + plainData @skip(if: $rich) + } + } + } + } +} diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql new file mode 100644 index 00000000000..785c88c185a --- /dev/null +++ b/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql @@ -0,0 +1,24 @@ +#import '~/graphql_shared/fragments/blobviewer.fragment.graphql' + +query SnippetBlobFull($ids: [ID!]) { + snippets(ids: $ids) { + edges { + node { + id + blob { + binary + name + path + rawPath + size + simpleViewer { + ...BlobViewer + } + richViewer { + ...BlobViewer + } + } + } + } + } +} diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index 1cb2c86c4d8..c58a5168ba3 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -1,6 +1,6 @@ #import '../fragments/snippetBase.fragment.graphql' #import '../fragments/project.fragment.graphql' -#import '../fragments/author.fragment.graphql' +#import "~/graphql_shared/fragments/author.fragment.graphql" query GetSnippetQuery($ids: [ID!]) { snippets(ids: $ids) { @@ -8,7 +8,9 @@ query GetSnippetQuery($ids: [ID!]) { node { ...SnippetBase ...Project - ...Author + author { + ...Author + } } } } diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index 1a1f3e8d0a8..2727485fb95 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,5 +1,3 @@ -import 'core-js/es/map'; -import 'core-js/es/set'; import { Sortable } from 'sortablejs'; import simulateDrag from './simulate_drag'; import simulateInput from './simulate_input'; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 157d89a3a40..5cc22f62262 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -3,108 +3,102 @@ import Vue from 'vue'; import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; -let renderedPopover; -let renderFn; - -const handleUserPopoverMouseOut = event => { - const { target } = event; - target.removeEventListener('mouseleave', handleUserPopoverMouseOut); - - if (renderFn) { - clearTimeout(renderFn); - } - if (renderedPopover) { - renderedPopover.$destroy(); - renderedPopover = null; - } - target.removeAttribute('aria-describedby'); +const removeTitle = el => { + // Removing titles so its not showing tooltips also + + el.dataset.originalTitle = ''; + el.setAttribute('title', ''); +}; + +const getPreloadedUserInfo = dataset => { + const userId = dataset.user || dataset.userId; + const { username, name, avatarUrl } = dataset; + + return { + userId, + username, + name, + avatarUrl, + }; }; /** * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes. * loads based on data-user-id more data about a user from the API and sets it on the popover */ -const handleUserPopoverMouseOver = event => { - const { target } = event; - // Add listener to actually remove it again - target.addEventListener('mouseleave', handleUserPopoverMouseOut); - - renderFn = setTimeout(() => { - // Helps us to use current markdown setup without maybe breaking or duplicating for now - if (target.dataset.user) { - target.dataset.userId = target.dataset.user; - // Removing titles so its not showing tooltips also - target.dataset.originalTitle = ''; - target.setAttribute('title', ''); - } - - const { userId, username, name, avatarUrl } = target.dataset; - const user = { - userId, - username, - name, - avatarUrl, - location: null, - bio: null, - organization: null, - status: null, - loaded: false, - }; - if (userId || username) { - const UserPopoverComponent = Vue.extend(UserPopover); - renderedPopover = new UserPopoverComponent({ +const populateUserInfo = user => { + const { userId } = user; + + return Promise.all([UsersCache.retrieveById(userId), UsersCache.retrieveStatusById(userId)]).then( + ([userData, status]) => { + if (userData) { + Object.assign(user, { + avatarUrl: userData.avatar_url, + username: userData.username, + name: userData.name, + location: userData.location, + bio: userData.bio, + organization: userData.organization, + loaded: true, + }); + } + + if (status) { + Object.assign(user, { + status, + }); + } + + return user; + }, + ); +}; + +const initializedPopovers = new Map(); + +export default (elements = document.querySelectorAll('.js-user-link')) => { + const userLinks = Array.from(elements); + const UserPopoverComponent = Vue.extend(UserPopover); + + return userLinks + .filter(({ dataset }) => dataset.user || dataset.userId) + .map(el => { + if (initializedPopovers.has(el)) { + return initializedPopovers.get(el); + } + + const user = { + location: null, + bio: null, + organization: null, + status: null, + loaded: false, + }; + const renderedPopover = new UserPopoverComponent({ propsData: { - target, + target: el, user, }, }); + initializedPopovers.set(el, renderedPopover); + renderedPopover.$mount(); - UsersCache.retrieveById(userId) - .then(userData => { - if (!userData) { - return undefined; - } - - Object.assign(user, { - avatarUrl: userData.avatar_url, - username: userData.username, - name: userData.name, - location: userData.location, - bio: userData.bio, - organization: userData.organization, - status: userData.status, - loaded: true, - }); - - if (userData.status) { - return Promise.resolve(); - } - - return UsersCache.retrieveStatusById(userId); - }) - .then(status => { - if (!status) { - return; - } - - Object.assign(user, { - status, - }); - }) - .catch(() => { - renderedPopover.$destroy(); - renderedPopover = null; - }); - } - }, 200); // 200ms delay so not every mouseover triggers Popover + API Call -}; + el.addEventListener('mouseenter', ({ target }) => { + removeTitle(target); + const preloadedUserInfo = getPreloadedUserInfo(target.dataset); + + Object.assign(user, preloadedUserInfo); -export default elements => { - const userLinks = elements || [...document.querySelectorAll('.js-user-link')]; + if (preloadedUserInfo.userId) { + populateUserInfo(user); + } + }); + el.addEventListener('mouseleave', ({ target }) => { + target.removeAttribute('aria-describedby'); + }); - userLinks.forEach(el => { - el.addEventListener('mouseenter', handleUserPopoverMouseOver); - }); + return renderedPopover; + }); }; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 6d7d863f273..6821df57b5a 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ +/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js new file mode 100644 index 00000000000..6550eb31491 --- /dev/null +++ b/app/assets/javascripts/vue_alerts.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue'; + +const mountVueAlert = el => { + const props = { + html: el.innerHTML, + }; + const attrs = { + ...el.dataset, + dismissible: parseBoolean(el.dataset.dismissible), + }; + + return new Vue({ + el, + render(h) { + return h(DismissibleAlert, { props, attrs }); + }, + }); +}; + +export default () => [...document.querySelectorAll('.js-vue-alert')].map(mountVueAlert); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index db4a4ece002..33db9b87b17 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -32,12 +32,12 @@ export default { }, }, deployedTextMap: { - [MANUAL_DEPLOY]: __('Can deploy manually to'), + [MANUAL_DEPLOY]: __('Can be manually deployed to'), [WILL_DEPLOY]: __('Will deploy to'), [RUNNING]: __('Deploying to'), [SUCCESS]: __('Deployed to'), [FAILED]: __('Failed to deploy to'), - [CANCELED]: __('Canceled deploy to'), + [CANCELED]: __('Canceled deployment to'), }, computed: { deployTimeago() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 2aaba6e1c8a..7c71463c949 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -77,7 +77,7 @@ export default { }; </script> <template> - <div class="mr-source-target append-bottom-default"> + <div class="d-flex mr-source-target append-bottom-default"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue new file mode 100644 index 00000000000..f08bfb3a90f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -0,0 +1,45 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import MrWidgetIcon from './mr_widget_icon.vue'; + +export default { + name: 'MRWidgetSuggestPipeline', + iconName: 'status_notfound', + components: { + GlLink, + GlSprintf, + MrWidgetIcon, + }, + props: { + pipelinePath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="d-flex mr-pipeline-suggest append-bottom-default"> + <mr-widget-icon :name="$options.iconName" /> + <gl-sprintf + class="js-no-pipeline-message" + :message=" + s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} + %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} + to create one.`) + " + > + <template #prefixToLink="{content}"> + <strong> + {{ content }} + </strong> + </template> + <template #addPipelineLink="{content}"> + <gl-link :href="pipelinePath" class="ml-2"> + {{ content }} + </gl-link> + + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index cf26003d038..a5e3115397a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -12,7 +12,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically') }} </span> + <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 75d1e5865b0..9df0c045fe4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -61,7 +61,7 @@ export default { eventHub.$emit('EnablePolling'); }, updateTimer() { - this.timer = this.timer - 1; + this.timer -= 1; if (this.timer === 0) { this.refresh(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index d230ac566de..66167a0d748 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -146,9 +146,15 @@ export default { auto_merge_strategy: useAutoMerge ? this.mr.preferredAutoMergeStrategy : undefined, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, - squash_commit_message: this.squashCommitMessage, }; + // If users can't alter the squash message (e.g. for 1-commit merge requests), + // we shouldn't send the commit message because that would make the backend + // do unnecessary work. + if (this.shouldShowSquashBeforeMerge) { + options.squash_commit_message = this.squashCommitMessage; + } + this.isMakingRequest = true; this.service .merge(options) diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 0cedbdbdfef..7a9ef7e496e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -5,6 +5,8 @@ import Translate from '../vue_shared/translate'; Vue.use(Translate); export default () => { + if (gl.mrWidget) return; + gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; const vm = new Vue(MrWidgetOptions); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 38a7c262b3e..27f13ace779 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -9,6 +9,7 @@ import SmartInterval from '~/smart_interval'; import createFlash from '../flash'; import Loading from './components/loading.vue'; import WidgetHeader from './components/mr_widget_header.vue'; +import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; import Deployment from './components/deployment/deployment.vue'; @@ -46,6 +47,7 @@ export default { components: { Loading, 'mr-widget-header': WidgetHeader, + 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, 'mr-widget-merge-help': WidgetMergeHelp, MrWidgetPipelineContainer, Deployment, @@ -99,6 +101,9 @@ export default { shouldRenderPipelines() { return this.mr.hasCI; }, + shouldSuggestPipelines() { + return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; + }, shouldRenderRelatedLinks() { return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, @@ -121,8 +126,14 @@ export default { ); }, mergeError() { + let { mergeError } = this.mr; + + if (mergeError && mergeError.slice(-1) === '.') { + mergeError = mergeError.slice(0, -1); + } + return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), { - mergeError: this.mr.mergeError, + mergeError, }); }, }, @@ -135,15 +146,11 @@ export default { }, }, mounted() { - if (gon && gon.features && gon.features.asyncMrWidget) { - MRWidgetService.fetchInitialData() - .then(({ data }) => this.initWidget(data)) - .catch(() => - createFlash(__('Unable to load the merge request widget. Try reloading the page.')), - ); - } else { - this.initWidget(); - } + MRWidgetService.fetchInitialData() + .then(({ data }) => this.initWidget(data)) + .catch(() => + createFlash(__('Unable to load the merge request widget. Try reloading the page.')), + ); }, beforeDestroy() { eventHub.$off('mr.discussion.updated', this.checkStatus); @@ -351,6 +358,11 @@ export default { <template> <div v-if="mr" class="mr-state-widget prepend-top-default"> <mr-widget-header :mr="mr" /> + <mr-widget-suggest-pipeline + v-if="shouldSuggestPipelines" + class="mr-widget-workflow" + :pipeline-path="mr.mergeRequestAddCiConfigPath" + /> <mr-widget-pipeline-container v-if="shouldRenderPipelines" class="mr-widget-workflow" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 3ab229567f6..a298331c1fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -7,7 +7,7 @@ export default function deviseState(data) { return stateKey.missingBranch; } else if (!data.commits_count) { return stateKey.nothingToMerge; - } else if (this.mergeStatus === 'unchecked') { + } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') { return stateKey.checking; } else if (data.has_conflicts) { return stateKey.conflicts; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index c7949fa264e..73a0b3cb673 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -175,6 +175,8 @@ export default class MergeRequestStore { this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; + this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; + this.humanAccess = data.human_access; } get isNothingToMergeState() { diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue deleted file mode 100644 index 25d7bfe515c..00000000000 --- a/app/assets/javascripts/vue_shared/components/bar_chart.vue +++ /dev/null @@ -1,351 +0,0 @@ -<script> -import * as d3 from 'd3'; -import tooltip from '../directives/tooltip'; -import Icon from './icon.vue'; -import SvgGradient from './svg_gradient.vue'; -import { - GRADIENT_COLORS, - GRADIENT_OPACITY, - INVERSE_GRADIENT_COLORS, - INVERSE_GRADIENT_OPACITY, -} from './bar_chart_constants'; - -/** - * Renders a bar chart that can be dragged(scrolled) when the number - * of elements to renders surpasses that of the available viewport space - * while keeping even padding and a width of 24px (customizable) - * - * It can render data with the following format: - * graphData: [{ - * name: 'element' // x domain data - * value: 1 // y domain data - * }] - * - * Used in: - * - Contribution analytics - all of the rows describing pushes, merge requests and issues - */ - -export default { - directives: { - tooltip, - }, - components: { - Icon, - SvgGradient, - }, - props: { - graphData: { - type: Array, - required: true, - }, - barWidth: { - type: Number, - required: false, - default: 24, - }, - yAxisLabel: { - type: String, - required: true, - }, - }, - data() { - return { - minX: -40, - minY: 0, - vbWidth: 0, - vbHeight: 0, - vpWidth: 0, - vpHeight: 200, - preserveAspectRatioType: 'xMidYMin meet', - containerMargin: { - leftRight: 30, - }, - viewBoxMargin: { - topBottom: 100, - }, - panX: 0, - xScale: {}, - yScale: {}, - zoom: {}, - bars: {}, - xGraphRange: 0, - isLoading: true, - paddingThreshold: 50, - showScrollIndicator: false, - showLeftScrollIndicator: false, - isGrabbed: false, - isPanAvailable: false, - gradientColors: GRADIENT_COLORS, - gradientOpacity: GRADIENT_OPACITY, - inverseGradientColors: INVERSE_GRADIENT_COLORS, - inverseGradientOpacity: INVERSE_GRADIENT_OPACITY, - maxTextWidth: 72, - rectYAxisLabelDims: {}, - xAxisTextElements: {}, - yAxisRectTransformPadding: 20, - yAxisTextTransformPadding: 10, - yAxisTextRotation: 90, - }; - }, - computed: { - svgViewBox() { - return `${this.minX} ${this.minY} ${this.vbWidth} ${this.vbHeight}`; - }, - xAxisLocation() { - return `translate(${this.panX}, ${this.vbHeight})`; - }, - barTranslationTransform() { - return `translate(${this.panX}, 0)`; - }, - scrollIndicatorTransform() { - return `translate(${this.vbWidth - 80}, 0)`; - }, - activateGrabCursor() { - return { - 'svg-graph-container-with-grab': this.isPanAvailable, - 'svg-graph-container-grabbed': this.isPanAvailable && this.isGrabbed, - }; - }, - yAxisLabelRectTransform() { - const rectWidth = - this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0; - const yCoord = this.vbHeight / 2 - rectWidth; - - return `translate(${this.minX - this.yAxisRectTransformPadding}, ${yCoord})`; - }, - yAxisLabelTextTransform() { - const rectWidth = - this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0; - const yCoord = this.vbHeight / 2 + rectWidth - 5; - - return `translate(${this.minX + this.yAxisTextTransformPadding}, ${yCoord}) rotate(-${ - this.yAxisTextRotation - })`; - }, - }, - mounted() { - if (!this.allValuesEmpty) { - this.draw(); - } - }, - methods: { - draw() { - // update viewport - this.vpWidth = this.$refs.svgContainer.clientWidth - this.containerMargin.leftRight; - // update viewbox - this.vbWidth = this.vpWidth; - this.vbHeight = this.vpHeight - this.viewBoxMargin.topBottom; - let padding = 0; - if (this.graphData.length * this.barWidth > this.vbWidth) { - this.xGraphRange = this.graphData.length * this.barWidth; - padding = this.calculatePadding(this.barWidth); - this.showScrollIndicator = true; - this.isPanAvailable = true; - } else { - this.xGraphRange = this.vbWidth - Math.abs(this.minX); - } - - this.xScale = d3 - .scaleBand() - .range([0, this.xGraphRange]) - .round(true) - .paddingInner(padding); - this.yScale = d3.scaleLinear().rangeRound([this.vbHeight, 0]); - - this.xScale.domain(this.graphData.map(d => d.name)); - this.yScale.domain([0, d3.max(this.graphData.map(d => d.value))]); - - // Zoom/Panning Function - this.zoom = d3 - .zoom() - .translateExtent([[0, 0], [this.xGraphRange, this.vbHeight]]) - .on('zoom', this.panGraph) - .on('end', this.removeGrabStyling); - - const xAxis = d3.axisBottom().scale(this.xScale); - - const yAxis = d3 - .axisLeft() - .scale(this.yScale) - .ticks(4); - - const renderedXAxis = d3 - .select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - this.xAxisTextElements = this.$refs.xAxis.querySelectorAll('text'); - - renderedXAxis.select('.domain').remove(); - - renderedXAxis - .selectAll('text') - .style('text-anchor', 'end') - .attr('dx', '-.3em') - .attr('dy', '-.95em') - .attr('class', 'tick-text') - .attr('transform', 'rotate(-90)'); - - renderedXAxis.selectAll('line').remove(); - - const { maxTextWidth } = this; - renderedXAxis.selectAll('text').each(function formatText() { - const axisText = d3.select(this); - let textLength = axisText.node().getComputedTextLength(); - let textContent = axisText.text(); - while (textLength > maxTextWidth && textContent.length > 0) { - textContent = textContent.slice(0, -1); - axisText.text(`${textContent}...`); - textLength = axisText.node().getComputedTextLength(); - } - }); - - const width = this.vbWidth; - - const renderedYAxis = d3 - .select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis); - - renderedYAxis.selectAll('.tick').each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } - }); - - // Add the panning capabilities - if (this.isPanAvailable) { - d3.select(this.$refs.baseSvg) - .call(this.zoom) - .on('wheel.zoom', null); // This disables the pan of the graph with the scroll of the mouse wheel - } - - this.isLoading = false; - // Update the yAxisLabel coordinates - const labelDims = this.$refs.yAxisLabel.getBBox(); - this.rectYAxisLabelDims = { - height: labelDims.width + 10, - }; - }, - panGraph() { - const allowedRightScroll = this.xGraphRange - this.vbWidth - this.paddingThreshold; - const graphMaxPan = Math.abs(d3.event.transform.x) < allowedRightScroll; - this.isGrabbed = true; - this.panX = d3.event.transform.x; - - if (d3.event.transform.x === 0) { - this.showLeftScrollIndicator = false; - } else { - this.showLeftScrollIndicator = true; - this.showScrollIndicator = true; - } - - if (!graphMaxPan) { - this.panX = -1 * (this.xGraphRange - this.vbWidth + this.paddingThreshold); - this.showScrollIndicator = false; - } - }, - setTooltipTitle(data) { - return data !== null ? `${data.name}: ${data.value}` : ''; - }, - calculatePadding(desiredBarWidth) { - const widthWithMargin = this.vbWidth - Math.abs(this.minX); - const dividend = widthWithMargin - this.graphData.length * desiredBarWidth; - const divisor = widthWithMargin - desiredBarWidth; - - return dividend / divisor; - }, - removeGrabStyling() { - this.isGrabbed = false; - }, - barHoveredIn(index) { - this.xAxisTextElements[index].classList.add('x-axis-text'); - }, - barHoveredOut(index) { - this.xAxisTextElements[index].classList.remove('x-axis-text'); - }, - }, -}; -</script> -<template> - <div ref="svgContainer" :class="activateGrabCursor" class="svg-graph-container"> - <svg - ref="baseSvg" - class="svg-graph overflow-visible pt-5" - :width="vpWidth" - :height="vpHeight" - :viewBox="svgViewBox" - :preserveAspectRatio="preserveAspectRatioType" - > - <g ref="xAxis" :transform="xAxisLocation" class="x-axis" /> - <g v-if="!isLoading"> - <template v-for="(data, index) in graphData"> - <rect - :key="index" - v-tooltip - :width="xScale.bandwidth()" - :x="xScale(data.name)" - :y="yScale(data.value)" - :height="vbHeight - yScale(data.value)" - :transform="barTranslationTransform" - :title="setTooltipTitle(data)" - class="bar-rect" - data-placement="top" - @mouseover="barHoveredIn(index)" - @mouseout="barHoveredOut(index)" - /> - </template> - </g> - <rect :height="vbHeight + 100" transform="translate(-100, -5)" width="100" fill="#fff" /> - <g class="y-axis-label"> - <line :x1="0" :x2="0" :y1="0" :y2="vbHeight" transform="translate(-35, 0)" stroke="black" /> - <!-- Get text length and change the height of this rect accordingly --> - <rect - :height="rectYAxisLabelDims.height" - :transform="yAxisLabelRectTransform" - :width="30" - fill="#fff" - /> - <text ref="yAxisLabel" :transform="yAxisLabelTextTransform">{{ yAxisLabel }}</text> - </g> - <g class="y-axis" /> - <g v-if="showScrollIndicator"> - <rect - :height="vbHeight + 100" - :transform="`translate(${vpWidth - 60}, -5)`" - width="40" - fill="#fff" - /> - <icon - :x="vpWidth - 50" - :y="vbHeight / 2" - :width="14" - :height="14" - name="chevron-right" - class="animate-flicker" - /> - </g> - <!-- The line that shows up when the data elements surpass the available width --> - <g v-if="showScrollIndicator" :transform="scrollIndicatorTransform"> - <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#shadow-gradient)" /> - </g> - <!-- Left scroll indicator --> - <g v-if="showLeftScrollIndicator" transform="translate(0, 0)"> - <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#left-shadow-gradient)" /> - </g> - <svg-gradient - :colors="gradientColors" - :opacity="gradientOpacity" - identifier-name="shadow-gradient" - /> - <svg-gradient - :colors="inverseGradientColors" - :opacity="inverseGradientOpacity" - identifier-name="left-shadow-gradient" - /> - </svg> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js b/app/assets/javascripts/vue_shared/components/bar_chart_constants.js deleted file mode 100644 index 6957b112da6..00000000000 --- a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const GRADIENT_COLORS = ['#000', '#a7a7a7']; -export const GRADIENT_OPACITY = ['0', '0.4']; -export const INVERSE_GRADIENT_COLORS = ['#a7a7a7', '#000']; -export const INVERSE_GRADIENT_OPACITY = ['0.4', '0']; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js new file mode 100644 index 00000000000..d4c1808eec2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -0,0 +1,3 @@ +export const HIGHLIGHT_CLASS_NAME = 'hll'; + +export default {}; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/index.js b/app/assets/javascripts/vue_shared/components/blob_viewers/index.js new file mode 100644 index 00000000000..72fba9392f9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/index.js @@ -0,0 +1,4 @@ +import RichViewer from './rich_viewer.vue'; +import SimpleViewer from './simple_viewer.vue'; + +export { RichViewer, SimpleViewer }; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js new file mode 100644 index 00000000000..582213ee8d3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -0,0 +1,8 @@ +export default { + props: { + content: { + type: String, + required: true, + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue new file mode 100644 index 00000000000..b3a1df8f303 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -0,0 +1,10 @@ +<script> +import ViewerMixin from './mixins'; + +export default { + mixins: [ViewerMixin], +}; +</script> +<template> + <div v-html="content"></div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue new file mode 100644 index 00000000000..e64c7132117 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -0,0 +1,68 @@ +<script> +import ViewerMixin from './mixins'; +import { GlIcon } from '@gitlab/ui'; +import { HIGHLIGHT_CLASS_NAME } from './constants'; + +export default { + components: { + GlIcon, + }, + mixins: [ViewerMixin], + data() { + return { + highlightedLine: null, + }; + }, + computed: { + lineNumbers() { + return this.content.split('\n').length; + }, + }, + mounted() { + const { hash } = window.location; + if (hash) this.scrollToLine(hash, true); + }, + methods: { + scrollToLine(hash, scroll = false) { + const lineToHighlight = hash && this.$el.querySelector(hash); + const currentlyHighlighted = this.highlightedLine; + if (lineToHighlight) { + if (currentlyHighlighted) { + currentlyHighlighted.classList.remove(HIGHLIGHT_CLASS_NAME); + } + + lineToHighlight.classList.add(HIGHLIGHT_CLASS_NAME); + this.highlightedLine = lineToHighlight; + if (scroll) { + lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> +<template> + <div + class="file-content code js-syntax-highlight qa-file-content" + :class="$options.userColorScheme" + > + <div class="line-numbers"> + <a + v-for="line in lineNumbers" + :id="`L${line}`" + :key="line" + class="diff-line-num js-line-number" + :href="`#LC${line}`" + :data-line-number="line" + @click="scrollToLine(`#LC${line}`)" + > + <gl-icon :size="12" name="link" /> + {{ line }} + </a> + </div> + <div class="blob-content"> + <pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 75c3c544c77..9ec99ac93d7 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -41,7 +41,7 @@ export default { changedIcon() { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - const suffix = !this.file.changed && this.file.staged && this.showStagedIcon ? '-solid' : ''; + const suffix = this.file.staged && this.showStagedIcon ? '-solid' : ''; return `${getCommitIconMap(this.file).icon}${suffix}`; }, @@ -49,25 +49,19 @@ export default { return `${this.changedIcon} float-left d-block`; }, tooltipTitle() { - if (!this.showTooltip) return undefined; + if (!this.showTooltip || !this.file.changed) return undefined; const type = this.file.tempFile ? 'addition' : 'modification'; - if (this.file.changed && !this.file.staged) { - return sprintf(__('Unstaged %{type}'), { - type, - }); - } else if (!this.file.changed && this.file.staged) { + if (this.file.staged) { return sprintf(__('Staged %{type}'), { type, }); - } else if (this.file.changed && this.file.staged) { - return sprintf(__('Unstaged and staged %{type}'), { - type, - }); } - return undefined; + return sprintf(__('Unstaged %{type}'), { + type, + }); }, showIcon() { return ( diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 9f498037185..3ff1d9cf48a 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -12,8 +12,7 @@ * css-class="btn-transparent" * /> */ -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import Icon from '../components/icon.vue'; +import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; export default { name: 'ClipboardButton', @@ -22,7 +21,7 @@ export default { }, components: { GlButton, - Icon, + GlIcon, }, props: { text: { @@ -72,6 +71,6 @@ export default { :title="title" :data-clipboard-text="clipboardText" > - <icon name="duplicate" /> + <gl-icon name="copy-to-clipboard" /> </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index fe1a2a092ad..e80cb06edfb 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -13,6 +13,11 @@ export default { type: String, required: true, }, + filePath: { + type: String, + required: false, + default: '', + }, fileSize: { type: Number, required: false, @@ -24,7 +29,8 @@ export default { return numberToHumanSize(this.fileSize); }, fileName() { - return this.path.split('/').pop(); + // path could be a base64 uri too, so check if filePath was passed additionally + return (this.filePath || this.path).split('/').pop(); }, }, }; @@ -39,7 +45,13 @@ export default { ({{ fileSizeReadable }}) </template> </p> - <gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank"> + <gl-link + :href="path" + class="btn btn-default" + rel="nofollow" + :download="fileName" + target="_blank" + > <icon :size="16" name="download" class="float-left append-right-8" /> {{ __('Download') }} </gl-link> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue new file mode 100644 index 00000000000..9ac687f5e2c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -0,0 +1,218 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; + +import Icon from '~/vue_shared/components/icon.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import DateTimePickerInput from './date_time_picker_input.vue'; +import { + defaultTimeRanges, + defaultTimeRange, + isValidDate, + stringToISODate, + ISODateToString, + truncateZerosInDateTime, + isDateTimePickerInputValid, +} from './date_time_picker_lib'; + +const events = { + input: 'input', + invalid: 'invalid', +}; + +export default { + components: { + Icon, + TooltipOnTruncate, + DateTimePickerInput, + GlFormGroup, + GlButton, + GlDropdown, + GlDropdownItem, + }, + props: { + value: { + type: Object, + required: false, + default: () => defaultTimeRange, + }, + options: { + type: Array, + required: false, + default: () => defaultTimeRanges, + }, + }, + data() { + return { + timeRange: this.value, + startDate: '', + endDate: '', + }; + }, + computed: { + startInputValid() { + return isValidDate(this.startDate); + }, + endInputValid() { + return isValidDate(this.endDate); + }, + isValid() { + return this.startInputValid && this.endInputValid; + }, + + startInput: { + get() { + return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + this.timeRange = null; + }, + }, + endInput: { + get() { + return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + this.timeRange = null; + }, + }, + + timeWindowText() { + try { + const timeRange = findTimeRange(this.value, this.options); + if (timeRange) { + return timeRange.label; + } + + const { start, end } = convertToFixedRange(this.value); + if (isValidDate(start) && isValidDate(end)) { + return sprintf(__('%{start} to %{end}'), { + start: this.formatDate(start), + end: this.formatDate(end), + }); + } + } catch { + return __('Invalid date range'); + } + return ''; + }, + }, + watch: { + value(newValue) { + const { start, end } = convertToFixedRange(newValue); + this.timeRange = this.value; + this.startDate = start; + this.endDate = end; + }, + }, + mounted() { + try { + const { start, end } = convertToFixedRange(this.timeRange); + this.startDate = start; + this.endDate = end; + } catch { + // when dates cannot be parsed, emit error. + this.$emit(events.invalid); + } + + // Validate on mounted, and trigger an update if needed + if (!this.isValid) { + this.$emit(events.invalid); + } + }, + methods: { + formatDate(date) { + return truncateZerosInDateTime(ISODateToString(date)); + }, + closeDropdown() { + this.$refs.dropdown.hide(); + }, + isOptionActive(option) { + return isEqualTimeRanges(option, this.timeRange); + }, + setQuickRange(option) { + this.timeRange = option; + this.$emit(events.input, this.timeRange); + }, + setFixedRange() { + this.timeRange = convertToFixedRange({ + start: this.startDate, + end: this.endDate, + }); + this.$emit(events.input, this.timeRange); + }, + }, +}; +</script> +<template> + <tooltip-on-truncate + :title="timeWindowText" + :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')" + placement="top" + class="d-inline-block" + > + <gl-dropdown + :text="timeWindowText" + v-bind="$attrs" + class="date-time-picker w-100" + menu-class="date-time-picker-menu" + toggle-class="date-time-picker-toggle text-truncate" + > + <div class="d-flex justify-content-between gl-p-2"> + <gl-form-group + :label="__('Custom range')" + label-for="custom-from-time" + label-class="gl-pb-1" + class="custom-time-range-form-group col-md-7 gl-pl-1 gl-pr-0 m-0" + > + <div class="gl-pt-2"> + <date-time-picker-input + id="custom-time-from" + v-model="startInput" + :label="__('From')" + :state="startInputValid" + /> + <date-time-picker-input + id="custom-time-to" + v-model="endInput" + :label="__('To')" + :state="endInputValid" + /> + </div> + <gl-form-group> + <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> + <gl-button variant="success" :disabled="!isValid" @click="setFixedRange()"> + {{ __('Apply') }} + </gl-button> + </gl-form-group> + </gl-form-group> + <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-pl-1 gl-pr-1 m-0"> + <template #label> + <span class="gl-pl-5">{{ __('Quick range') }}</span> + </template> + + <gl-dropdown-item + v-for="(option, index) in options" + :key="index" + :active="isOptionActive(option)" + active-class="active" + @click="setQuickRange(option)" + > + <icon + name="mobile-issue-close" + class="align-bottom" + :class="{ invisible: !isOptionActive(option) }" + /> + {{ option.label }} + </gl-dropdown-item> + </gl-form-group> + </div> + </gl-dropdown> + </tooltip-on-truncate> +</template> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue index c3beae18726..f19f8bd46b3 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue @@ -1,14 +1,14 @@ <script> -import _ from 'underscore'; +import { uniqueId } from 'lodash'; import { GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; -import { dateFormats } from '~/monitoring/constants'; +import { __, sprintf } from '~/locale'; +import { dateFormats } from './date_time_picker_lib'; const inputGroupText = { - invalidFeedback: sprintf(s__('Format: %{dateFormat}'), { - dateFormat: dateFormats.dateTimePicker.format, + invalidFeedback: sprintf(__('Format: %{dateFormat}'), { + dateFormat: dateFormats.stringDate, }), - placeholder: dateFormats.dateTimePicker.format, + placeholder: dateFormats.stringDate, }; export default { @@ -35,7 +35,7 @@ export default { id: { type: String, required: false, - default: () => _.uniqueId('dateTimePicker_'), + default: () => uniqueId('dateTimePicker_'), }, }, data() { diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js new file mode 100644 index 00000000000..673d981cf07 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -0,0 +1,84 @@ +import dateformat from 'dateformat'; +import { __ } from '~/locale'; + +/** + * Valid strings for this regex are + * 2019-10-01 and 2019-10-01 01:02:03 + */ +const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/; + +/** + * Default time ranges for the date picker. + * @see app/assets/javascripts/lib/utils/datetime_range.js + */ +export const defaultTimeRanges = [ + { + duration: { seconds: 60 * 30 }, + label: __('30 minutes'), + }, + { + duration: { seconds: 60 * 60 * 3 }, + label: __('3 hours'), + }, + { + duration: { seconds: 60 * 60 * 8 }, + label: __('8 hours'), + default: true, + }, + { + duration: { seconds: 60 * 60 * 24 * 1 }, + label: __('1 day'), + }, +]; + +export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default); + +export const dateFormats = { + ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'", + stringDate: 'yyyy-mm-dd HH:MM:ss', +}; + +/** + * The URL params start and end need to be validated + * before passing them down to other components. + * + * @param {string} dateString + * @returns true if the string is a valid date, false otherwise + */ +export const isValidDate = dateString => { + try { + // dateformat throws error that can be caught. + // This is better than using `new Date()` + if (dateString && dateString.trim()) { + dateformat(dateString, 'isoDateTime'); + return true; + } + return false; + } catch (e) { + return false; + } +}; + +/** + * Convert the input in Time picker component to ISO date. + * + * @param {string} val + * @returns {string} + */ +export const stringToISODate = val => + dateformat(new Date(val.replace(/-/g, '/')), dateFormats.ISODate, true); + +/** + * Convert the ISO date received from the URL to string + * for the Time picker component. + * + * @param {Date} date + * @returns {string} + */ +export const ISODateToString = date => dateformat(date, dateFormats.stringDate); + +export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', ''); + +export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val); + +export default {}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index b874bedab36..bf3c3666300 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -79,10 +79,10 @@ export default { return this.projectPath.indexOf('/') === 0 ? '' : `${gon.relative_url_root}/`; }, fullOldPath() { - return `${this.basePath}${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`; + return `${this.basePath}${this.projectPath}/-/raw/${this.oldSha}/${this.oldPath}`; }, fullNewPath() { - return `${this.basePath}${this.projectPath}/raw/${this.newSha}/${this.newPath}`; + return `${this.basePath}${this.projectPath}/-/raw/${this.newSha}/${this.newPath}`; }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue new file mode 100644 index 00000000000..986fa14349e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -0,0 +1,32 @@ +<script> +import { GlAlert } from '@gitlab/ui'; + +export default { + components: { + GlAlert, + }, + props: { + html: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isDismissed: false, + }; + }, + methods: { + dismiss() { + this.isDismissed = true; + }, + }, +}; +</script> + +<template> + <gl-alert v-if="!isDismissed" v-bind="$attrs" @dismiss="dismiss" v-on="$listeners"> + <div v-html="html"></div> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue index c35fee84771..9aca210c1fb 100644 --- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue @@ -69,7 +69,7 @@ export default { data-display="static" data-toggle="dropdown" > - <icon name="arrow-down" :aria-label="__('toggle dropdown')" /> + <icon name="chevron-down" :aria-label="__('toggle dropdown')" /> </button> <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> <template v-for="(action, index) in actions"> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 611001df32f..578fcc819b0 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,16 +1,12 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { name: 'FileRow', components: { FileHeader, FileIcon, - Icon, - ChangedFileIcon, }, props: { file: { @@ -21,26 +17,6 @@ export default { type: Number, required: true, }, - extraComponent: { - type: Object, - required: false, - default: null, - }, - hideExtraOnTree: { - type: Boolean, - required: false, - default: false, - }, - showChangedIcon: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - dropdownOpen: false, - }; }, computed: { isTree() { @@ -62,9 +38,6 @@ export default { 'is-open': this.file.opened, }; }, - childFilesLevel() { - return this.file.isHeader ? 0 : this.level + 1; - }, }, watch: { 'file.active': function fileActiveWatch(active) { @@ -123,61 +96,36 @@ export default { return this.$router.currentRoute.path === `/project${this.file.url}`; }, - toggleDropdown(val) { - this.dropdownOpen = val; - }, }, }; </script> <template> - <div> - <file-header v-if="file.isHeader" :path="file.path" /> - <div - v-else - :class="fileClass" - :title="file.name" - class="file-row" - role="button" - @click="clickFile" - @mouseleave="toggleDropdown(false)" - > - <div class="file-row-name-container"> - <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> - <file-icon - v-if="!showChangedIcon || file.type === 'tree'" - class="file-row-icon" - :file-name="file.name" - :loading="file.loading" - :folder="isTree" - :opened="file.opened" - :size="16" - /> - <changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> - {{ file.name }} - </span> - <component - :is="extraComponent" - v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" - :file="file" - :dropdown-open="dropdownOpen" - @toggle="toggleDropdown($event)" + <file-header v-if="file.isHeader" :path="file.path" /> + <div + v-else + :class="fileClass" + :title="file.name" + class="file-row" + role="button" + @click="clickFile" + @mouseleave="$emit('mouseleave', $event)" + > + <div class="file-row-name-container"> + <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> + <file-icon + class="file-row-icon" + :class="{ 'text-secondary': file.type === 'tree' }" + :file-name="file.name" + :loading="file.loading" + :folder="isTree" + :opened="file.opened" + :size="16" /> - </div> + {{ file.name }} + </span> + <slot></slot> </div> - <template v-if="file.opened || file.isHeader"> - <file-row - v-for="childFile in file.tree" - :key="childFile.key" - :file="childFile" - :level="childFilesLevel" - :hide-extra-on-tree="hideExtraOnTree" - :extra-component="extraComponent" - :show-changed-icon="showChangedIcon" - @toggleTreeOpen="toggleTreeOpen" - @clickFile="clickedFile" - /> - </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue new file mode 100644 index 00000000000..e7817b8f910 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_tree.vue @@ -0,0 +1,47 @@ +<script> +export default { + name: 'FileTree', + props: { + fileRowComponent: { + type: Object, + required: true, + }, + level: { + type: Number, + required: true, + }, + file: { + type: Object, + required: true, + }, + }, + computed: { + childFilesLevel() { + return this.file.isHeader ? 0 : this.level + 1; + }, + }, +}; +</script> + +<template> + <div> + <component + :is="fileRowComponent" + :level="level" + :file="file" + v-bind="$attrs" + v-on="$listeners" + /> + <template v-if="file.opened || file.isHeader"> + <file-tree + v-for="childFile in file.tree" + :key="childFile.key" + :file-row-component="fileRowComponent" + :level="childFilesLevel" + :file="childFile" + v-bind="$attrs" + v-on="$listeners" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index dba4a9231a1..876eb7b899c 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; /** * Renders header component for job and pipeline page based on UI mockups @@ -20,7 +19,6 @@ export default { UserAvatarImage, GlLink, GlButton, - LoadingButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -47,11 +45,6 @@ export default { required: false, default: () => ({}), }, - actions: { - type: Array, - required: false, - default: () => [], - }, hasSidebarButton: { type: Boolean, required: false, @@ -71,9 +64,6 @@ export default { }, methods: { - onClickAction(action) { - this.$emit('actionClicked', action); - }, onClickSidebarButton() { this.$emit('clickedSidebarButton'); }, @@ -115,18 +105,8 @@ export default { </template> </section> - <section v-if="actions.length" class="header-action-buttons"> - <template v-for="(action, i) in actions"> - <loading-button - :key="i" - :loading="action.isLoading" - :disabled="action.isLoading" - :class="action.cssClass" - container-class="d-inline" - :label="action.label" - @click="onClickAction(action)" - /> - </template> + <section v-if="$slots.default" class="header-action-buttons"> + <slot></slot> </section> <gl-button v-if="hasSidebarButton" diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index d42f0d8192c..9dd61c8eada 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -29,7 +29,7 @@ export default { </script> <template> - <div :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon"> + <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon"> {{ identiconTitle }} </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index 47f0851f650..b5d3f3685bc 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -65,14 +65,14 @@ export default { <div class="issuable-note-warning"> <icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> - <span v-if="isLockedAndConfidential"> + <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> <span v-html="confidentialAndLockedDiscussionText"></span> {{ __("People without permission will never get a notification and won't be able to comment.") }} </span> - <span v-else-if="isConfidential"> + <span v-else-if="isConfidential" ref="confidential"> {{ __('This is a confidential issue.') }} {{ __('People without permission will never get a notification.') }} <gl-link :href="confidentialIssueDocsPath" target="_blank"> @@ -80,7 +80,7 @@ export default { </gl-link> </span> - <span v-else-if="isLocked"> + <span v-else-if="isLocked" ref="locked"> {{ __('This issue is locked.') }} {{ __('Only project members can comment.') }} <gl-link :href="lockedIssueDocsPath" target="_blank"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 4f5f3ee5cf9..e30876813c2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -79,6 +79,12 @@ export default { required: false, default: false, }, + // This prop is used as a fallback in case if textarea.elm is undefined + textareaValue: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -183,7 +189,7 @@ export default { Can't use `$refs` as the component is technically in the parent component so we access the VNode & then get the element */ - const text = this.$slots.textarea[0].elm.value; + const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue; if (text) { this.markdownPreviewLoading = true; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index fee5d6d5e3a..36cbb230d30 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -128,8 +128,8 @@ export default { @click="handleSuggestDismissed" /> <gl-popover - v-if="showSuggestPopover" - :target="() => $refs.suggestButton" + v-if="showSuggestPopover && $refs.suggestButton" + :target="$refs.suggestButton" :css-classes="['diff-suggest-popover']" placement="bottom" :show="showSuggestPopover" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index 97d93eaaf3f..112bd03b49b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -8,6 +8,9 @@ export default { }, }, computed: { + displayAsCell() { + return !(this.line.rich_text || this.line.text); + }, lineType() { return this.line.type; }, @@ -23,11 +26,9 @@ export default { <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType"> {{ line.new_line }} </td> - <td class="line_content" :class="lineType"> + <td class="line_content" :class="[{ 'd-table-cell': displayAsCell }, lineType]"> <span v-if="line.rich_text" v-html="line.rich_text"></span> <span v-else-if="line.text">{{ line.text }}</span> - <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> - <span v-else>​</span> </td> </tr> </template> diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index cdcfff42981..271a375ade2 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -121,7 +121,7 @@ export default { :title="title" > <slot> - <icon name="duplicate" /> + <icon name="copy-to-clipboard" /> </slot> </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index af02b8969ee..69afd711797 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -47,7 +47,7 @@ export default { :img-size="40" /> </div> - <div :class="{ discussion: !note.individual_note }" class="timeline-content"> + <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content"> <div class="note-header"> <div class="note-header-info"> <a :href="getUserData.path"> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 15ca64ba297..0c4d75fb0ad 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -17,11 +17,12 @@ * /> */ import $ from 'jquery'; -import { mapGetters, mapActions } from 'vuex'; -import { GlSkeletonLoading } from '@gitlab/ui'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import { GlButton, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; @@ -34,9 +35,13 @@ export default { Icon, noteHeader, TimelineEntryItem, + GlButton, GlSkeletonLoading, }, - mixins: [descriptionVersionHistoryMixin], + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], props: { note: { type: Object, @@ -50,6 +55,7 @@ export default { }, computed: { ...mapGetters(['targetNoteHash']), + ...mapState(['descriptionVersion', 'isLoadingDescriptionVersion']), noteAnchorId() { return `note_${this.note.id}`; }, @@ -80,7 +86,7 @@ export default { initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); }, methods: { - ...mapActions(['fetchDescriptionVersion']), + ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), }, }; </script> @@ -122,6 +128,16 @@ export default { <gl-skeleton-loading /> </pre> <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> + <gl-button + v-if="canDeleteDescriptionVersion" + ref="deleteDescriptionVersionButton" + v-gl-tooltip + :title="__('Remove description history')" + class="btn-transparent delete-description-history" + @click="deleteDescriptionVersion" + > + <icon name="remove" /> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue deleted file mode 100644 index 53e473432db..00000000000 --- a/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { PREV, NEXT } from '~/vue_shared/components/pagination/constants'; - -/** - * Pagination Component for graphql API - */ -export default { - name: 'GraphqlPaginationComponent', - components: { - GlButton, - }, - labels: { - prev: PREV, - next: NEXT, - }, - props: { - hasNextPage: { - required: true, - type: Boolean, - }, - hasPreviousPage: { - required: true, - type: Boolean, - }, - }, -}; -</script> -<template> - <div class="justify-content-center d-flex prepend-top-default"> - <div class="btn-group"> - <gl-button - class="js-prev-btn page-link" - :disabled="!hasPreviousPage" - @click="$emit('previousClicked')" - >{{ $options.labels.prev }}</gl-button - > - - <gl-button - class="js-next-btn page-link" - :disabled="!hasNextPage" - @click="$emit('nextClicked')" - >{{ $options.labels.next }}</gl-button - > - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 69eb791d195..4ea3d162da2 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,5 +1,5 @@ <script> -import _ from 'underscore'; +import { isFunction } from 'lodash'; import tooltip from '../directives/tooltip'; export default { @@ -28,16 +28,18 @@ export default { showTooltip: false, }; }, + watch: { + title() { + // Wait on $nextTick in case of slot width changes + this.$nextTick(this.updateTooltip); + }, + }, mounted() { - const target = this.selectTarget(); - - if (target && target.scrollWidth > target.offsetWidth) { - this.showTooltip = true; - } + this.updateTooltip(); }, methods: { selectTarget() { - if (_.isFunction(this.truncateTarget)) { + if (isFunction(this.truncateTarget)) { return this.truncateTarget(this.$el); } else if (this.truncateTarget === 'child') { return this.$el.childNodes[0]; @@ -45,6 +47,10 @@ export default { return this.$el; }, + updateTooltip() { + const target = this.selectTarget(); + this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 37e3643bf6c..ca25d9ee738 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -56,19 +56,16 @@ export default { </script> <template> - <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show> + <!-- 200ms delay so not every mouseover triggers Popover --> + <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top"> <div class="user-popover d-flex"> <div class="p-1 flex-shrink-1"> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> </div> <div class="p-1 w-100"> <h5 class="m-0"> - {{ user.name }} - <gl-skeleton-loading - v-if="nameIsLoading" - :lines="1" - class="animation-container-small mb-1" - /> + <span v-if="user.name">{{ user.name }}</span> + <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </h5> <div class="text-secondary mb-2"> <span v-if="user.username">@{{ user.username }}</span> diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js index ced847294ae..4f558843357 100644 --- a/app/assets/javascripts/webpack.js +++ b/app/assets/javascripts/webpack.js @@ -5,5 +5,5 @@ */ if (gon && gon.webpack_public_path) { - __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase + __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line babel/camelcase } diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 044d703630e..ab0b0b02aa8 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-return, camelcase, class-methods-use-this */ +/* eslint-disable consistent-return, class-methods-use-this */ // Zen Mode (full screen) textarea // @@ -91,8 +91,8 @@ export default class ZenMode { } } - scrollTo(zen_area) { - return $.scrollTo(zen_area, 0, { + scrollTo(zenArea) { + return $.scrollTo(zenArea, 0, { offset: -150, }); } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e98030f1511..657e52674db 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,7 +11,7 @@ // like a table or typography then make changes in the framework/ directory. // If you need to add unique style that should affect only one page - use pages/ // directory. -@import "at.js/dist/css/jquery.atwho"; +@import "@gitlab/at.js/dist/css/jquery.atwho"; @import "dropzone/dist/basic"; @import "select2/select2"; diff --git a/app/assets/stylesheets/components/date_time_picker.scss b/app/assets/stylesheets/components/date_time_picker.scss new file mode 100644 index 00000000000..21f085cdaf1 --- /dev/null +++ b/app/assets/stylesheets/components/date_time_picker.scss @@ -0,0 +1,5 @@ +.date-time-picker { + .date-time-picker-menu { + width: 400px; + } +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 249e9a24b17..9032dd28b80 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -15,6 +15,7 @@ @import 'framework/badges'; @import 'framework/calendar'; @import 'framework/callout'; +@import 'framework/carousel'; @import 'framework/common'; @import 'framework/dropdowns'; @import 'framework/files'; diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 53a8f7c483a..0e4080ce201 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -69,7 +69,6 @@ &.footer-block { margin-top: $gl-padding-24; border-bottom: 0; - margin-bottom: -$gl-padding; } &.content-component-block { @@ -326,11 +325,11 @@ } .btn { - margin: $btn-side-margin 5px; + margin: $gl-padding-8 $gl-padding-4; @include media-breakpoint-down(xs) { width: 100%; - margin: $btn-side-margin 0; + margin: $gl-padding-8 0; } } } diff --git a/app/assets/stylesheets/framework/carousel.scss b/app/assets/stylesheets/framework/carousel.scss new file mode 100644 index 00000000000..d51a9f9c173 --- /dev/null +++ b/app/assets/stylesheets/framework/carousel.scss @@ -0,0 +1,202 @@ +// Notes on the classes: +// +// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically) +// even when their scroll action started on a carousel, but for compatibility (with Firefox) +// we're preventing all actions instead +// 2. The .carousel-item-left and .carousel-item-right is used to indicate where +// the active slide is heading. +// 3. .active.carousel-item is the current slide. +// 4. .active.carousel-item-left and .active.carousel-item-right is the current +// slide in its in-transition state. Only one of these occurs at a time. +// 5. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right +// is the upcoming slide in transition. + +.carousel { + position: relative; + + &.pointer-event { + touch-action: pan-y; + } +} + + +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; + @include clearfix(); +} + +.carousel-item { + position: relative; + display: none; + float: left; + width: 100%; + margin-right: -100%; + backface-visibility: hidden; + @include transition($carousel-transition); +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-left), +.active.carousel-item-right { + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-right), +.active.carousel-item-left { + transform: translateX(-100%); +} + + +// +// Alternate transitions +// + +.carousel-fade { + .carousel-item { + opacity: 0; + transition-property: opacity; + transform: none; + } + + .carousel-item.active, + .carousel-item-next.carousel-item-left, + .carousel-item-prev.carousel-item-right { + z-index: 1; + opacity: 1; + } + + .active.carousel-item-left, + .active.carousel-item-right { + z-index: 0; + opacity: 0; + @include transition(0s $carousel-transition-duration opacity); + } +} + + +// +// Left/right controls for nav +// + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + // Use flex for alignment (1-3) + display: flex; // 1. allow flex styles + align-items: center; // 2. vertically center contents + justify-content: center; // 3. horizontally center contents + width: $carousel-control-width; + color: $carousel-control-color; + text-align: center; + opacity: $carousel-control-opacity; + @include transition($carousel-control-transition); + + // Hover/focus state + @include hover-focus { + color: $carousel-control-color; + text-decoration: none; + outline: 0; + opacity: $carousel-control-hover-opacity; + } +} + +.carousel-control-prev { + left: 0; + @if $enable-gradients { + background: linear-gradient(90deg, rgba($black, 0.25), rgba($black, 0.001)); + } +} + +.carousel-control-next { + right: 0; + @if $enable-gradients { + background: linear-gradient(270deg, rgba($black, 0.25), rgba($black, 0.001)); + } +} + +// Icons for within +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: $carousel-control-icon-width; + height: $carousel-control-icon-width; + background: no-repeat 50% / 100% 100%; +} + +.carousel-control-prev-icon { + background-image: $carousel-control-prev-icon-bg; +} + +.carousel-control-next-icon { + background-image: $carousel-control-next-icon-bg; +} + + +// Optional indicator pips +// +// Add an ordered list with the following class and add a list item for each +// slide your carousel holds. + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 15; + display: flex; + justify-content: center; + padding-left: 0; // override <ol> default + // Use the .carousel-control's width as margin so we don't overlay those + margin-right: $carousel-control-width; + margin-left: $carousel-control-width; + list-style: none; + + li { + box-sizing: content-box; + flex: 0 1 auto; + width: $carousel-indicator-width; + height: $carousel-indicator-height; + margin-right: $carousel-indicator-spacer; + margin-left: $carousel-indicator-spacer; + text-indent: -999px; + cursor: pointer; + background-color: $carousel-indicator-active-bg; + background-clip: padding-box; + // Use transparent borders to increase the hit area by 10px on top and bottom. + border-top: $carousel-indicator-hit-area-height solid transparent; + border-bottom: $carousel-indicator-hit-area-height solid transparent; + opacity: 0.5; + @include transition($carousel-indicator-transition); + } + + .active { + opacity: 1; + } +} + + +// Optional captions +// +// + +.carousel-caption { + position: absolute; + right: (100% - $carousel-caption-width) / 2; + bottom: 20px; + left: (100% - $carousel-caption-width) / 2; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: $carousel-caption-color; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index dc119b52f4e..408ca249be2 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -581,6 +581,7 @@ img.emoji { .gl-line-height-24 { line-height: $gl-line-height-24; } .gl-line-height-14 { line-height: $gl-line-height-14; } +.gl-font-size-0 { font-size: 0; } .gl-font-size-12 { font-size: $gl-font-size-12; } .gl-font-size-14 { font-size: $gl-font-size-14; } .gl-font-size-16 { font-size: $gl-font-size-16; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 21253e004ef..41f3603506f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -158,6 +158,27 @@ } } +// Temporary hack until `gitlab-ui` issue is fixed. +// https://gitlab.com/gitlab-org/gitlab-ui/issues/164 +.gl-dropdown .dropdown-menu-toggle { + .gl-dropdown-caret { + position: absolute; + right: $gl-padding-8; + top: $gl-padding-8; + } + + // Add some child to the button so that the default height kicks in + // when there's no text (since the caret is now aboslute) + &::after { + border: 0; + content: ' '; + display: inline-block; + margin: 0; + padding: 0; + position: relative; + } +} + @mixin dropdown-item-hover { background-color: $gray-darker; color: $gl-text-color; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1a017f03ebb..bb1c304b9fe 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -499,3 +499,15 @@ span.idiff { background-color: transparent; border: transparent; } + +.code-navigation { + border-bottom: 1px $gray-darkest dashed; + + &:hover { + border-bottom-color: $almost-black; + } +} + +.code-navigation-popover { + max-width: 450px; +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index b5d1c3f6732..4b45a169a31 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -190,6 +190,7 @@ min-width: 0; border: 1px solid $border-color; background-color: $white-light; + border-radius: $border-radius-default 0 0 $border-radius-default; @include media-breakpoint-down(sm) { flex: 1 1 auto; @@ -287,7 +288,7 @@ .filtered-search-history-dropdown-toggle-button { flex: 1; width: auto; - border-radius: 0; + border-radius: $border-radius-default 0 0 $border-radius-default; border: 0; border-right: 1px solid $border-color; color: $gl-text-color-secondary; @@ -296,8 +297,7 @@ &:hover, &:focus { color: $gl-text-color; - border-color: $blue-300; - outline: none; + border-color: $border-color; } svg { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index ee6e53adaf7..73a2170fc68 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -30,7 +30,6 @@ .line { display: block; width: 100%; - min-height: 1.5em; padding-left: 10px; padding-right: 10px; white-space: pre; @@ -48,10 +47,10 @@ font-family: $monospace-font; display: block; font-size: $code-font-size !important; - min-height: 1.5em; white-space: nowrap; - i { + i, + svg { float: left; margin-top: 3px; margin-right: 5px; @@ -62,12 +61,20 @@ &:focus { outline: none; - i { + i, + svg { visibility: visible; } } } } + + pre .line, + .line-numbers a { + font-size: 0.8125rem; + line-height: 1.1875rem; + min-height: 1.1875rem; + } } // Vertically aligns <table> line numbers (eg. blame view) diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 757264add93..ac8437c23ca 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -70,6 +70,7 @@ margin: 0; } + .btn + .btn, .btn + .btn-group, .btn-group + .btn, .btn-group + .btn-group { diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index bd0134a82d3..a8244219b10 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -63,7 +63,8 @@ display: block; } - .select2-choices { + .select2-choices, + .select2-choice { border-color: $red-500; } } diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 404f60f17ee..dbcb5086d70 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -1,6 +1,7 @@ .snippet-row { .title { margin-bottom: 2px; + font-weight: $gl-font-weight-bold; } .snippet-filename { @@ -11,6 +12,10 @@ .snippet-info { color: $gl-text-color-secondary; } + + a { + color: $gl-text-color; + } } .snippet-form-holder .file-holder .file-title { @@ -27,10 +32,6 @@ .snippet-file-content { border-radius: 3px; - - .file-title-flex-parent .btn-clipboard { - line-height: 28px; - } } .snippet-header { diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index 91fe75075dc..5e05311041c 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -49,3 +49,9 @@ @include spinner-color($white); } } + +.btn { + .spinner { + vertical-align: text-bottom; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 90600ecf615..e4853ca7bf5 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -579,7 +579,7 @@ $calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* - * Cycle Analytics + * Value Stream Analytics */ $cycle-analytics-box-padding: 30px; $cycle-analytics-box-text-color: #8c8c8c; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 420271c9a1e..9c64714e5dd 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -25,10 +25,6 @@ $ide-commit-header-height: 48px; @include str-truncated(250px); } -.editable-mode { - display: inline-block; -} - .ide-view { position: relative; margin-top: 0; @@ -164,6 +160,11 @@ $ide-commit-header-height: 48px; height: 0; } +// stylelint-disable selector-class-pattern +// stylelint-disable selector-max-compound-selectors +// stylelint-disable stylelint-gitlab/duplicate-selectors +// stylelint-disable stylelint-gitlab/utility-classes + .blob-editor-container { flex: 1; height: 0; @@ -295,8 +296,8 @@ $ide-commit-header-height: 48px; height: 100%; min-height: 0; // firefox fix - &.is-readonly, - .editor.original { + &.is-readonly .vs, + .vs .editor.original { .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input { @@ -305,6 +306,11 @@ $ide-commit-header-height: 48px; } } +// stylelint-enable selector-class-pattern +// stylelint-enable selector-max-compound-selectors +// stylelint-enable stylelint-gitlab/duplicate-selectors +// stylelint-enable stylelint-gitlab/utility-classes + .preview-container { flex-grow: 1; position: relative; @@ -332,23 +338,6 @@ $ide-commit-header-height: 48px; padding: $gl-padding; max-width: 100%; max-height: 100%; - - img { - max-width: 90%; - } - - .isZoomable { - cursor: pointer; - cursor: zoom-in; - - &.isZoomed { - cursor: pointer; - cursor: zoom-out; - max-width: none; - max-height: none; - margin-right: $gl-padding; - } - } } .file-info { @@ -361,13 +350,9 @@ $ide-commit-header-height: 48px; .ide-mode-tabs { border-bottom: 1px solid $white-dark; - .nav-links { - border-bottom: 0; - - li a { - padding: $gl-padding-8 $gl-padding; - line-height: $gl-btn-line-height; - } + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; } } @@ -564,12 +549,6 @@ $ide-commit-header-height: 48px; background: $gray-100; outline: 0; - - .multi-file-discard-btn { - > .btn { - display: flex; - } - } } &:active { @@ -596,18 +575,6 @@ $ide-commit-header-height: 48px; } } -.multi-file-discard-btn { - > .btn { - display: none; - width: $ide-commit-row-height; - height: $ide-commit-row-height; - } - - svg { - top: 0; - } -} - .multi-file-commit-form { position: relative; background-color: $white-light; @@ -721,7 +688,7 @@ $ide-commit-header-height: 48px; font-weight: normal; &.is-disabled { - .ide-radio-label { + .ide-option-label { text-decoration: line-through; } } @@ -1060,8 +1027,6 @@ $ide-commit-header-height: 48px; } .ide-external-link { - position: relative; - svg { display: none; position: absolute; @@ -1164,22 +1129,12 @@ $ide-commit-header-height: 48px; align-items: center; } } - - .card-body { - padding: 0; - } } .ide-stage-collapse-icon { margin: auto 0 auto auto; } -.ide-stage-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .ide-job-header { min-height: 60px; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 31e87d1a7cf..42d7b0d08f7 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -287,6 +287,10 @@ cursor: help; } + .issue-blocked-icon { + color: $red-500; + } + @include media-breakpoint-down(md) { padding: $gl-padding-8; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index be0311f584f..781b6c09458 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -321,6 +321,16 @@ } } +.gpg-popover-certificate-details { + ul { + padding-left: $gl-padding; + } + + li.unstyled { + list-style-type: none; + } +} + .gpg-popover-status { display: flex; align-items: center; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 76cd4f34865..89b673397a2 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -109,14 +109,6 @@ top: $gl-padding-top; } - .fa-spinner { - font-size: 28px; - position: relative; - margin-left: -20px; - left: 50%; - margin-top: 36px; - } - .stage-panel-body { display: flex; flex-wrap: wrap; @@ -200,7 +192,7 @@ .stage-events { width: 60%; overflow: scroll; - height: 467px; + min-height: 467px; } .stage-event-list { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index f394e4ab58a..24c6fec064a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,9 +14,9 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { - // The `-1` below is to prevent two borders from clashing up against eachother - + // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header - $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1; + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height + 11; position: -webkit-sticky; position: sticky; @@ -63,11 +63,6 @@ background-color: $gray-normal; } - a, - button { - color: $gray-700; - } - svg { vertical-align: middle; top: -1px; @@ -552,7 +547,7 @@ table.code { .diff-stats { align-items: center; - padding: 0 0.25rem; + padding: 0 1rem; .diff-stats-group { padding: 0 0.25rem; @@ -564,7 +559,7 @@ table.code { &.is-compare-versions-header { .diff-stats-group { - padding: 0 0.5rem; + padding: 0 0.25rem; } } } @@ -1059,8 +1054,8 @@ table.code { .diff-tree-list { position: -webkit-sticky; position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; - top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px; + top: $top-pos; max-height: calc(100vh - #{$top-pos}); z-index: 202; @@ -1097,10 +1092,7 @@ table.code { .tree-list-scroll { max-height: 100%; - padding-top: $grid-size; padding-bottom: $grid-size; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; overflow-y: scroll; overflow-x: auto; } diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index 5a80ea79600..710d89d9341 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -3,31 +3,8 @@ background-color: $gray-light; } - .gitlab-logo { - width: 80px; - height: 80px; - } - .signup-box-container { - max-width: 900px; - - &.navless-container { - // overriding .devise-layout-html.navless-container to support the sticky footer - // without having a header on size xs - @include media-breakpoint-down(xs) { - padding: 65px $gl-padding; // height of footer - padding-top: $gl-padding; - } - } - } - - .signup-heading h2 { - font-weight: $gl-font-weight-bold; - padding: 0 $gl-padding; - - @include media-breakpoint-down(md) { - font-size: $gl-font-size-large; - } + max-width: 960px; } .signup-box { @@ -49,4 +26,35 @@ color: $red-700; } } + + .omniauth-divider { + &::before, + &::after { + content: ''; + flex: 1; + border-bottom: 1px solid $gray-dark; + margin: $gl-padding-24 0; + } + + &::before { + margin-right: $gl-padding; + } + + &::after { + margin-left: $gl-padding; + } + } + + .omniauth-btn { + width: 48%; + + @include media-breakpoint-down(md) { + width: 100%; + } + + img { + width: $default-icon-size; + height: $default-icon-size; + } + } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 1cf72c51ca7..3085f5e89b5 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -382,8 +382,6 @@ table.pipeline-project-metrics tr td { } .group-row-contents { - padding: $gl-padding; - &:hover { border-color: $blue-200; background-color: $blue-50; @@ -410,13 +408,7 @@ table.pipeline-project-metrics tr td { .title { margin-top: -$gl-padding-8; // negative margin required for flex-wrap - font-size: $gl-font-size-large; - } - - @include media-breakpoint-down(md) { - .title { - font-size: $gl-font-size; - } + font-size: $gl-font-size; } &.has-more-items { @@ -483,7 +475,6 @@ table.pipeline-project-metrics tr td { .last-updated { position: relative; - right: 12px; min-width: 250px; text-align: right; color: $gl-text-color-secondary; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index c023c9e5cbd..5ca75c28ac3 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -3,6 +3,8 @@ * */ +$mr-widget-min-height: 69px; + .space-children { @include clearfix; @@ -555,12 +557,11 @@ } .mr-source-target { - display: flex; flex-wrap: wrap; border-radius: $border-radius-default; padding: $gl-padding; border: 1px solid $border-color; - min-height: 69px; + min-height: $mr-widget-min-height; @include media-breakpoint-up(md) { align-items: center; @@ -599,6 +600,22 @@ } } +.mr-pipeline-suggest { + flex-wrap: wrap; + border-radius: $border-radius-default; + padding: $gl-padding; + border: 1px solid $border-color; + min-height: $mr-widget-min-height; + + @include media-breakpoint-up(md) { + align-items: center; + } + + .circle-icon-container { + color: $gl-text-color-quaternary; + } +} + .card-new-merge-request { .card-header { padding: 5px 10px; @@ -708,7 +725,7 @@ .mr-version-controls { position: relative; z-index: 203; - background: $gray-light; + background: $white-light; color: $gl-text-color; margin-top: -1px; @@ -732,7 +749,7 @@ } .content-block { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding; border-bottom: 0; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 1da9f691639..1a06ae1ed41 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -311,13 +311,18 @@ $note-form-margin-left: 72px; overflow: hidden; .description-version { + position: relative; + + .btn.delete-description-history { + position: absolute; + top: 18px; + right: 0; + } + pre { max-height: $dropdown-max-height-lg; white-space: pre-wrap; - - &.loading-state { - height: 94px; - } + padding-right: 30px; } } diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss index 374227fe16a..93caa345f8a 100644 --- a/app/assets/stylesheets/pages/pages.scss +++ b/app/assets/stylesheets/pages/pages.scss @@ -56,4 +56,15 @@ border-top-right-radius: $border-radius-default; } + &.floating-status-badge { + position: absolute; + right: $gl-padding-24; + bottom: $gl-padding-4; + margin-bottom: 0; + } +} + +.form-control.has-floating-status-badge { + position: relative; + padding-right: 120px; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 8b2c67378d9..f8832047d49 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1006,14 +1006,6 @@ pre.light-well { } } - &:not(.with-pipeline-status) { - .icon-wrapper:first-of-type { - @include media-breakpoint-up(lg) { - margin-left: $gl-padding-32; - } - } - } - .ci-status-link { display: inline-flex; } diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index e20e58e21cf..8133a167687 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -47,16 +47,22 @@ } .prometheus-graphs-header { - .time-window-dropdown-menu { - padding: $gl-padding $gl-padding 0 $gl-padding-12; + .monitor-environment-dropdown-header header, + .monitor-dashboard-dropdown-header header { + font-size: $gl-font-size; } - .time-window-dropdown-menu-container { - width: 360px; - } + .monitor-environment-dropdown-menu, + .monitor-dashboard-dropdown-menu { + &.show { + display: flex; + flex-direction: column; + overflow: hidden; + } - .custom-time-range-form-group > label { - padding-bottom: $gl-padding; + .no-matches-message { + padding: $gl-padding-8 $gl-padding-12; + } } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 79ad0bd7735..db1b8c559e5 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -17,14 +17,12 @@ .tree-controls { text-align: right; - .btn { + > .btn, + .project-action-button > .btn, + .git-clone-holder > .btn { margin-left: 8px; } - .btn-group { - margin-left: 10px; - } - .control { float: left; margin-left: 10px; diff --git a/app/assets/stylesheets/pages/trials.scss b/app/assets/stylesheets/pages/trials.scss new file mode 100644 index 00000000000..3fb9054b2b8 --- /dev/null +++ b/app/assets/stylesheets/pages/trials.scss @@ -0,0 +1,15 @@ +/* +* A CSS cross-browser fix for Select2 failire to display HTML5 required warnings +* MR link https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22716 +*/ +.gl-select2-html5-required-fix div.select2-container+select.select2 { + display: block !important; + width: 1px; + height: 1px; + z-index: -1; + opacity: 0; + margin: -3px auto 0; + background-image: none; + background-color: transparent; + border: 0; +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 1517015dda0..0fd6aafef0d 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -28,6 +28,13 @@ } } +@for $i from 1 through 12 { + #{'.tab-width-#{$i}'} { + -moz-tab-size: $i; + tab-size: $i; + } +} + .border-width-1px { border-width: 1px; } .border-bottom-width-1px { border-bottom-width: 1px; } .border-style-dashed { border-style: dashed; } @@ -40,7 +47,10 @@ .mh-50vh { max-height: 50vh; } +.font-size-inherit { font-size: inherit; } + .gl-w-64 { width: px-to-rem($grid-size * 8); } +.gl-h-32 { height: px-to-rem($grid-size * 4); } .gl-h-64 { height: px-to-rem($grid-size * 8); } .gl-text-purple { color: $purple; } @@ -55,8 +65,8 @@ .gl-bg-green-100 { @include gl-bg-green-100;} .gl-text-blue-500 { @include gl-text-blue-500; } +.gl-text-gray-700 { @include gl-text-gray-700; } .gl-text-gray-900 { @include gl-text-gray-900; } .gl-text-red-700 { @include gl-text-red-700; } .gl-text-orange-700 { @include gl-text-orange-700; } .gl-text-green-700 { @include gl-text-green-700; } - diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 3047ee02680..54c9bde067d 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -3,18 +3,14 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController include InternalRedirect + # NOTE: Use @application_setting in this controller when you need to access + # application_settings after it has been modified. This is because the + # ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the + # cache might be stale immediately after an update. + # https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233 before_action :set_application_setting + before_action :whitelist_query_limiting, only: [:usage_data] - before_action :validate_self_monitoring_feature_flag_enabled, only: [ - :create_self_monitoring_project, - :status_create_self_monitoring_project, - :delete_self_monitoring_project, - :status_delete_self_monitoring_project - ] - - before_action do - push_frontend_feature_flag(:self_monitoring_project) - end VALID_SETTING_PANELS = %w(general integrations repository ci_cd reporting metrics_and_profiling @@ -31,10 +27,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController define_method(action) { perform_update if submitted? } end - def show - render :general - end - def update perform_update end @@ -64,10 +56,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def clear_repository_check_states - RepositoryCheck::ClearWorker.perform_async + RepositoryCheck::ClearWorker.perform_async # rubocop:disable CodeReuse/Worker redirect_to( - admin_application_settings_path, + general_admin_application_settings_path, notice: _('Started asynchronous removal of all repository check states.') ) end @@ -79,8 +71,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url end + # Specs are in spec/requests/self_monitoring_project_spec.rb def create_self_monitoring_project - job_id = SelfMonitoringProjectCreateWorker.perform_async + job_id = SelfMonitoringProjectCreateWorker.perform_async # rubocop:disable CodeReuse/Worker render status: :accepted, json: { job_id: job_id, @@ -88,6 +81,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def status_create_self_monitoring_project job_id = params[:job_id].to_s @@ -98,10 +92,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end - if Gitlab::CurrentSettings.instance_administration_project_id.present? - return render status: :ok, json: self_monitoring_data - - elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id) + if SelfMonitoringProjectCreateWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker ::Gitlab::PollingInterval.set_header(response, interval: 3_000) return render status: :accepted, json: { @@ -109,14 +100,19 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + if @application_setting.self_monitoring_project_id.present? + return render status: :ok, json: self_monitoring_data + end + render status: :bad_request, json: { message: _('Self-monitoring project does not exist. Please check logs ' \ 'for any error messages') } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def delete_self_monitoring_project - job_id = SelfMonitoringProjectDeleteWorker.perform_async + job_id = SelfMonitoringProjectDeleteWorker.perform_async # rubocop:disable CodeReuse/Worker render status: :accepted, json: { job_id: job_id, @@ -124,6 +120,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + # Specs are in spec/requests/self_monitoring_project_spec.rb def status_delete_self_monitoring_project job_id = params[:job_id].to_s @@ -134,12 +131,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end - if Gitlab::CurrentSettings.instance_administration_project_id.nil? - return render status: :ok, json: { - message: _('Self-monitoring project has been successfully deleted') - } - - elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id) + if SelfMonitoringProjectDeleteWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker ::Gitlab::PollingInterval.set_header(response, interval: 3_000) return render status: :accepted, json: { @@ -147,6 +139,12 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end + if @application_setting.self_monitoring_project_id.nil? + return render status: :ok, json: { + message: _('Self-monitoring project has been successfully deleted') + } + end + render status: :bad_request, json: { message: _('Self-monitoring project was not deleted. Please check logs ' \ 'for any error messages') @@ -155,27 +153,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController private - def validate_self_monitoring_feature_flag_enabled - self_monitoring_project_not_implemented unless Feature.enabled?(:self_monitoring_project) - end - def self_monitoring_data { - project_id: Gitlab::CurrentSettings.instance_administration_project_id, - project_full_path: Gitlab::CurrentSettings.instance_administration_project&.full_path + project_id: @application_setting.self_monitoring_project_id, + project_full_path: @application_setting.self_monitoring_project&.full_path } end - def self_monitoring_project_not_implemented - render( - status: :not_implemented, - json: { - message: _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'), - documentation_url: help_page_path('administration/monitoring/gitlab_instance_administration_project/index') - } - ) - end - def set_application_setting @application_setting = ApplicationSetting.current_without_cache end @@ -244,7 +228,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? end - redirect_path = referer_path(request) || admin_application_settings_path + redirect_path = referer_path(request) || general_admin_application_settings_path respond_to do |format| if successful diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 907b295870d..c017ecee054 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -55,6 +55,8 @@ class Admin::ApplicationsController < Admin::ApplicationController # Only allow a trusted parameter "white list" through. def application_params - params.require(:doorkeeper_application).permit(:name, :redirect_uri, :trusted, :scopes) + params + .require(:doorkeeper_application) + .permit(:name, :redirect_uri, :trusted, :scopes, :confidential) end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 5455cefdc8e..0245c00aacb 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -6,8 +6,7 @@ class Admin::GroupsController < Admin::ApplicationController before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update] def index - @groups = Group.with_statistics.with_route - @groups = @groups.sort_by_attribute(@sort = params[:sort]) + @groups = groups.sort_by_attribute(@sort = params[:sort]) @groups = @groups.search(params[:name]) if params[:name].present? @groups = @groups.page(params[:page]) end @@ -75,6 +74,10 @@ class Admin::GroupsController < Admin::ApplicationController private + def groups + Group.with_statistics.with_route + end + def group @group ||= Group.find_by_full_path(params[:id]) end diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb index 14245300633..3ae0aef0fa4 100644 --- a/app/controllers/admin/logs_controller.rb +++ b/app/controllers/admin/logs_controller.rb @@ -10,7 +10,7 @@ class Admin::LogsController < Admin::ApplicationController def loggers @loggers ||= [ - Gitlab::AppLogger, + Gitlab::AppJsonLogger, Gitlab::GitLogger, Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index cdedc34e634..7015da8bd50 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -55,7 +55,7 @@ class Admin::ProjectsController < Admin::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def repository_check - RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id) + RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id) # rubocop:disable CodeReuse/Worker redirect_to( admin_project_path(@project), diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 783c59822f1..9eaa55039c8 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -66,7 +66,7 @@ class Admin::RunnersController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def assign_builds_and_projects - @builds = runner.builds.order('id DESC').first(30) + @builds = runner.builds.order('id DESC').preload_project_and_pipeline_project.first(30) @projects = if params[:search].present? ::Project.search(params[:search]) @@ -75,7 +75,8 @@ class Admin::RunnersController < Admin::ApplicationController end @projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any? - @projects = @projects.page(params[:page]).per(30) + @projects = @projects.inc_routes + @projects = @projects.page(params[:page]).per(30).without_count end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/admin/serverless/domains_controller.rb b/app/controllers/admin/serverless/domains_controller.rb new file mode 100644 index 00000000000..c37aec13105 --- /dev/null +++ b/app/controllers/admin/serverless/domains_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Admin::Serverless::DomainsController < Admin::ApplicationController + before_action :check_feature_flag + before_action :domain, only: [:update, :verify] + + def index + @domain = PagesDomain.instance_serverless.first_or_initialize + end + + def create + if PagesDomain.instance_serverless.count > 0 + return redirect_to admin_serverless_domains_path, notice: _('An instance-level serverless domain already exists.') + end + + @domain = PagesDomain.instance_serverless.create(create_params) + + if @domain.persisted? + redirect_to admin_serverless_domains_path, notice: _('Domain was successfully created.') + else + render 'index' + end + end + + def update + if domain.update(update_params) + redirect_to admin_serverless_domains_path, notice: _('Domain was successfully updated.') + else + render 'index' + end + end + + def verify + result = VerifyPagesDomainService.new(domain).execute + + if result[:status] == :success + flash[:notice] = _('Successfully verified domain ownership') + else + flash[:alert] = _('Failed to verify domain ownership') + end + + redirect_to admin_serverless_domains_path + end + + private + + def domain + @domain = PagesDomain.instance_serverless.find(params[:id]) + end + + def check_feature_flag + render_404 unless Feature.enabled?(:serverless_domain) + end + + def update_params + params.require(:pages_domain).permit(:user_provided_certificate, :user_provided_key) + end + + def create_params + params.require(:pages_domain).permit(:domain, :user_provided_certificate, :user_provided_key) + end +end diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index e31e0e09978..50b79cde5c5 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -19,7 +19,7 @@ class Admin::ServicesController < Admin::ApplicationController def update if service.update(service_params[:service]) - PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? # rubocop:disable CodeReuse/Worker redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index a41d8a22650..689e502a221 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -24,7 +24,7 @@ class Admin::SpamLogsController < Admin::ApplicationController def mark_as_ham spam_log = SpamLog.find(params[:id]) - if HamService.new(spam_log).mark_as_ham! + if Spam::HamService.new(spam_log).execute redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.') else redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.') diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 9fbfc59f630..8414095d454 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -75,7 +75,9 @@ class Admin::UsersController < Admin::ApplicationController end def block - if update_user { |user| user.block } + result = Users::BlockService.new(current_user).execute(user) + + if result[:status] = :success redirect_back_or_admin_user(notice: _("Successfully blocked")) else redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked")) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index acbc25220a0..7cb629dee21 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -37,6 +37,7 @@ class ApplicationController < ActionController::Base around_action :set_current_context around_action :set_locale around_action :set_session_storage + around_action :set_current_admin after_action :set_page_title_header, if: :json_request? after_action :limit_session_time, if: -> { !current_user } @@ -120,7 +121,7 @@ class ApplicationController < ActionController::Base def render(*args) super.tap do # Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse - if (400..599).cover?(response.status) && workhorse_excluded_content_types.include?(response.content_type) + if (400..599).cover?(response.status) && workhorse_excluded_content_types.include?(response.media_type) response.headers['X-GitLab-Custom-Error'] = '1' end end @@ -454,6 +455,7 @@ class ApplicationController < ActionController::Base user: -> { auth_user }, project: -> { @project }, namespace: -> { @group }, + caller_id: full_action_name, &block) end @@ -472,6 +474,13 @@ class ApplicationController < ActionController::Base response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end + def set_current_admin(&block) + return yield unless Feature.enabled?(:user_mode_in_session) + return yield unless current_user + + Gitlab::Auth::CurrentUserMode.with_current_admin(current_user, &block) + end + def html_request? request.format.html? end @@ -551,6 +560,10 @@ class ApplicationController < ActionController::Base end end + def full_action_name + "#{self.class.name}##{action_name}" + end + # A user requires a role and have the setup_for_company attribute set when they are part of the experimental signup # flow (executed by the Growth team). Users are redirected to the welcome page when their role is required and the # experiment is enabled for the current user. diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 1d6711e3c22..5e14339bb07 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -20,6 +20,9 @@ module Boards skip_before_action :authenticate_user!, only: [:index] before_action :validate_id_list, only: [:bulk_move] before_action :can_move_issues?, only: [:bulk_move] + before_action do + push_frontend_feature_flag(:board_search_optimization, board.group) + end # rubocop: disable CodeReuse/ActiveRecord def index @@ -83,8 +86,12 @@ module Boards head(:forbidden) unless can?(current_user, :admin_issue, board) end + def serializer_options(issues) + {} + end + def render_issues(issues, metadata) - data = { issues: serialize_as_json(issues) } + data = { issues: serialize_as_json(issues, opts: serializer_options(issues)) } data.merge!(metadata) render json: data @@ -130,8 +137,10 @@ module Boards IssueSerializer.new(current_user: current_user) end - def serialize_as_json(resource) - serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) + def serialize_as_json(resource, opts: {}) + opts.merge!(include_full_project_path: board.group_board?, serializer: 'board') + + serializer.represent(resource, opts) end def whitelist_query_limiting diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 52a5f801bad..2c9ee69c8c4 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -12,9 +12,6 @@ class Clusters::ClustersController < Clusters::BaseController before_action :authorize_update_cluster!, only: [:update] before_action :authorize_admin_cluster!, only: [:destroy, :clear_cache] before_action :update_applications_status, only: [:cluster_status] - before_action only: [:show] do - push_frontend_feature_flag(:enable_cluster_application_elastic_stack) - end helper_method :token_in_session diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 8c8f0b3a22e..6f0c7abac16 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -21,21 +21,28 @@ module AuthenticatesWithTwoFactor # Set @user for Devise views @user = user # rubocop:disable Gitlab/ModuleWithInstanceVariables - return locked_user_redirect(user) unless user.can?(:log_in) + return handle_locked_user(user) unless user.can?(:log_in) session[:otp_user_id] = user.id setup_u2f_authentication(user) render 'devise/sessions/two_factor' end + def handle_locked_user(user) + clear_two_factor_attempt! + + locked_user_redirect(user) + end + def locked_user_redirect(user) - flash.now[:alert] = _('Invalid Login or password') + flash.now[:alert] = locked_user_redirect_alert(user) + render 'devise/sessions/new' end def authenticate_with_two_factor user = self.resource = find_user - return locked_user_redirect(user) unless user.can?(:log_in) + return handle_locked_user(user) unless user.can?(:log_in) if user_params[:otp_attempt].present? && session[:otp_user_id] authenticate_with_two_factor_via_otp(user) @@ -48,6 +55,14 @@ module AuthenticatesWithTwoFactor private + def locked_user_redirect_alert(user) + user.access_locked? ? _('Your account is locked.') : _('Invalid Login or password') + end + + def clear_two_factor_attempt! + session.delete(:otp_user_id) + end + def authenticate_with_two_factor_via_otp(user) if valid_otp_attempt?(user) # Remove any lingering user data from login diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 32e1a46e580..f1c0bcd491d 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -10,7 +10,7 @@ module ConfirmEmailWarning protected def show_confirm_warning? - html_request? && request.get? && Feature.enabled?(:soft_email_confirmation) + html_request? && request.get? end def set_confirm_warning diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index a78d803927c..3e67f1f54cb 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -10,9 +10,9 @@ module CycleAnalyticsParams end def cycle_analytics_group_params - return {} unless params[:cycle_analytics].present? + return {} unless params.present? - params[:cycle_analytics].permit(:start_date, :created_after, :created_before, project_ids: []) + params.permit(:group_id, :start_date, :created_after, :created_before, project_ids: []) end def options(params) diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb index d56f1d7fa5f..45c0a5c58ef 100644 --- a/app/controllers/concerns/invisible_captcha.rb +++ b/app/controllers/concerns/invisible_captcha.rb @@ -8,7 +8,7 @@ module InvisibleCaptcha end def on_honeypot_spam_callback - return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow) + return unless Feature.enabled?(:invisible_captcha) invisible_captcha_honeypot_counter.increment log_request('Invisible_Captcha_Honeypot_Request') @@ -17,7 +17,7 @@ module InvisibleCaptcha end def on_timestamp_spam_callback - return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow) + return unless Feature.enabled?(:invisible_captcha) invisible_captcha_timestamp_counter.increment log_request('Invisible_Captcha_Timestamp_Request') diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 61072eec535..3152d959ae4 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -112,10 +112,6 @@ module LfsRequest has_authentication_ability?(:build_download_code) && can?(user, :build_download_code, project) end - def storage_project - @storage_project ||= project.lfs_storage_project - end - def objects @objects ||= (params[:objects] || []).to_a end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 993f091b0e6..1cf9046e30f 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -21,9 +21,9 @@ module MembershipActions member = Members::UpdateService .new(current_user, update_params) .execute(member) - .present(current_user: current_user) - present_members([member]) + member = present_members([member]).first + respond_to do |format| format.js { render 'shared/members/update', locals: { member: member } } end diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index dc392147cb8..fa79f3bc4e6 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -5,6 +5,7 @@ module MetricsDashboard include RenderServiceResults include ChecksCollaboration + include EnvironmentsHelper extend ActiveSupport::Concern @@ -15,8 +16,9 @@ module MetricsDashboard metrics_dashboard_params.to_h.symbolize_keys ) - if include_all_dashboards? && result - result[:all_dashboards] = all_dashboards + if result + result[:all_dashboards] = all_dashboards if include_all_dashboards? + result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) if project_for_dashboard && environment_for_dashboard end respond_to do |format| @@ -76,10 +78,14 @@ module MetricsDashboard defined?(project) ? project : nil end + def environment_for_dashboard + defined?(environment) ? environment : nil + end + def dashboard_success_response(result) { status: :ok, - json: result.slice(:all_dashboards, :dashboard, :status) + json: result.slice(:all_dashboards, :dashboard, :status, :metrics_data) } end diff --git a/app/controllers/concerns/page_limiter.rb b/app/controllers/concerns/page_limiter.rb new file mode 100644 index 00000000000..3c280fa4f12 --- /dev/null +++ b/app/controllers/concerns/page_limiter.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Include this in your controller and call `limit_pages` in order +# to configure the limiter. +# +# Examples: +# class MyController < ApplicationController +# include PageLimiter +# +# before_action only: [:index] do +# limit_pages(500) +# end +# +# # You can override the default response +# rescue_from PageOutOfBoundsError, with: :page_out_of_bounds +# +# def page_out_of_bounds(error) +# # Page limit number is available as error.message +# head :ok +# end +# + +module PageLimiter + extend ActiveSupport::Concern + + PageLimiterError = Class.new(StandardError) + PageLimitNotANumberError = Class.new(PageLimiterError) + PageLimitNotSensibleError = Class.new(PageLimiterError) + PageOutOfBoundsError = Class.new(PageLimiterError) + + included do + rescue_from PageOutOfBoundsError, with: :default_page_out_of_bounds_response + end + + def limit_pages(max_page_number) + check_page_number!(max_page_number) + end + + private + + # If the page exceeds the defined maximum, raise a PageOutOfBoundsError + # If the page doesn't exceed the limit, it does nothing. + def check_page_number!(max_page_number) + raise PageLimitNotANumberError unless max_page_number.is_a?(Integer) + raise PageLimitNotSensibleError unless max_page_number > 0 + + if params[:page].present? && params[:page].to_i > max_page_number + record_page_limit_interception + raise PageOutOfBoundsError.new(max_page_number) + end + end + + # By default just return a HTTP status code and an empty response + def default_page_out_of_bounds_response + head :bad_request + end + + # Record the page limit being hit in Prometheus + def record_page_limit_interception + dd = DeviceDetector.new(request.user_agent) + + Gitlab::Metrics.counter(:gitlab_page_out_of_bounds, + controller: params[:controller], + action: params[:action], + bot: dd.bot? + ).increment + end +end diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 28e4cece548..2f5dc09be4a 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -3,7 +3,7 @@ module SendFileUpload def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment') if attachment - response_disposition = ::Gitlab::ContentDisposition.format(disposition: disposition, filename: attachment) + response_disposition = ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: attachment) # Response-Content-Type will not override an existing Content-Type in # Google Cloud Storage, so the metadata needs to be cleared on GCS for @@ -15,7 +15,7 @@ module SendFileUpload # cross-origin JavaScript protection. send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js' - send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment)) + send_params.merge!(filename: attachment, disposition: disposition) end if file_upload.file_storage? @@ -28,18 +28,6 @@ module SendFileUpload end end - # Since Rails 5 doesn't properly support support non-ASCII filenames, - # we have to add our own to ensure RFC 5987 compliance. However, Rails - # 5 automatically appends `filename#{filename}` here: - # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137 - # Rails 6 will have https://github.com/rails/rails/pull/33829, so we - # can get rid of this special case handling when we upgrade. - def utf8_encoded_disposition(disposition, filename) - content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename) - - "#{disposition}; #{content.utf8_filename}" - end - def guess_content_type(filename) types = MIME::Types.type_for(filename) diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 21ee76d31b2..f99345fa99d 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -11,7 +11,7 @@ class ConfirmationsController < Devise::ConfirmationsController protected def after_resending_confirmation_instructions_path_for(resource) - Feature.enabled?(:soft_email_confirmation) ? stored_location_for(resource) || dashboard_projects_path : users_almost_there_path + stored_location_for(resource) || dashboard_projects_path end def after_confirmation_path_for(resource_name, resource) diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 9659d7719b9..039991e07a2 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -66,6 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute + finder_params[:use_cte] = true if use_cte_for_finder? + projects = ProjectsFinder .new(params: finder_params, current_user: current_user) .execute @@ -77,6 +79,11 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + def use_cte_for_finder? + # The starred action loads public projects, which causes the CTE to be less efficient + action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true) + end + def load_events projects = load_projects(params.merge(non_public: true)) diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index 6feade3df03..aa09fcdbe61 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -7,6 +7,10 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController skip_cross_project_access_check :index def index + @snippet_counts = Snippets::CountService + .new(current_user, author: current_user) + .execute + @snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope]) .execute .page(params[:page]) diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 271f2b4b57d..a8a76b47bbe 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Explore::ProjectsController < Explore::ApplicationController + include PageLimiter include ParamsBackwardCompatibility include RendersMemberAccess include SortingHelper @@ -9,6 +10,13 @@ class Explore::ProjectsController < Explore::ApplicationController before_action :set_non_archived_param before_action :set_sorting + # Limit taken from https://gitlab.com/gitlab-org/gitlab/issues/38357 + before_action only: [:index, :trending, :starred] do + limit_pages(200) + end + + rescue_from PageOutOfBoundsError, with: :page_out_of_bounds + def index @projects = load_projects @@ -53,10 +61,14 @@ class Explore::ProjectsController < Explore::ApplicationController private - # rubocop: disable CodeReuse/ActiveRecord - def load_projects + def load_project_counts @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute + end + + # rubocop: disable CodeReuse/ActiveRecord + def load_projects + load_project_counts projects = ProjectsFinder.new(current_user: current_user, params: params) .execute @@ -80,4 +92,21 @@ class Explore::ProjectsController < Explore::ApplicationController def sorting_field Project::SORTING_PREFERENCE_FIELD end + + def page_out_of_bounds(error) + load_project_counts + @max_page_number = error.message + + respond_to do |format| + format.html do + render "page_out_of_bounds", status: :bad_request + end + + format.json do + render json: { + html: view_to_html_string("explore/projects/page_out_of_bounds") + }, status: :bad_request + end + end + end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index d03a50f6f77..0760bdf1e01 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -20,6 +20,14 @@ class Groups::ApplicationController < ApplicationController @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute end + def group_projects_with_subgroups + @group_projects_with_subgroups ||= GroupProjectsFinder.new( + group: group, + current_user: current_user, + options: { include_subgroups: true } + ).execute + end + def authorize_admin_group! unless can?(current_user, :admin_group, group) return render_404 diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 8c9bf17f017..fab84fb8299 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -4,6 +4,7 @@ class Groups::BoardsController < Groups::ApplicationController include BoardsActions include RecordUserLastActivity + before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:multi_select_board, default_enabled: true) @@ -16,4 +17,8 @@ class Groups::BoardsController < Groups::ApplicationController @namespace_path = group.to_param @labels_endpoint = group_labels_url(group) end + + def authorize_read_board! + access_denied! unless can?(current_user, :read_board, group) + end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 7eba73daa3c..a478e9fffb8 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -103,8 +103,15 @@ class Groups::MilestonesController < Groups::ApplicationController end def group_projects_with_access - group_projects.with_issues_available_for_user(current_user) - .or(group_projects.with_merge_requests_available_for_user(current_user)) + group_projects_with_subgroups.with_issues_or_mrs_available_for_user(current_user) + end + + def group_ids(include_ancestors: false) + if include_ancestors + group.self_and_hierarchy.public_or_visible_to_user(current_user).select(:id) + else + group.self_and_descendants.public_or_visible_to_user(current_user).select(:id) + end end def milestone @@ -119,7 +126,7 @@ class Groups::MilestonesController < Groups::ApplicationController end def search_params - groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id + groups = request.format.json? ? group_ids(include_ancestors: true) : group_ids params.permit(:state, :search_title).merge(group_ids: groups) end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index cfddd8a3ba9..84c25cfb180 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -7,20 +7,31 @@ module Groups before_action :feature_flag_group_container_registry_browser! def index - track_event(:list_repositories) - respond_to do |format| format.html format.json do @images = group.container_repositories.with_api_entity_associations - render json: ContainerRepositoriesSerializer + track_event(:list_repositories) + + serializer = ContainerRepositoriesSerializer .new(current_user: current_user) - .represent_read_only(@images) + + if Feature.enabled?(:vue_container_registry_explorer) + render json: serializer.with_pagination(request, response) + .represent_read_only(@images) + else + render json: serializer.represent_read_only(@images) + end end end end + # The show action renders index to allow frontend routing to work on page refresh + def show + render :index + end + private def feature_flag_group_container_registry_browser! diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index ca35b07111c..4c9aac9a327 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -3,10 +3,6 @@ class IdeController < ApplicationController layout 'fullscreen' - before_action do - push_frontend_feature_flag(:stage_all_by_default, default_enabled: true) - end - def index Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count end diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 9b45be6db99..04919a4b9d0 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true class Import::BaseController < ApplicationController + before_action :import_rate_limit, only: [:create] + private # rubocop: disable CodeReuse/ActiveRecord def find_already_added_projects(import_type) - current_user.created_projects.where(import_type: import_type).includes(:import_state) + current_user.created_projects.where(import_type: import_type).with_import_state end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def find_jobs(import_type) current_user.created_projects - .includes(:import_state) + .with_import_state .where(import_type: import_type) .to_json(only: [:id], methods: [:import_status]) end @@ -37,4 +39,18 @@ class Import::BaseController < ApplicationController def project_save_error(project) project.errors.full_messages.join(', ') end + + def import_rate_limit + key = "project_import".to_sym + + if rate_limiter.throttled?(key, scope: [current_user, key]) + rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) + + redirect_back_or_default(options: { alert: _('This endpoint has been requested too many times. Try again later.') }) + end + end + + def rate_limiter + ::Gitlab::ApplicationRateLimiter + end end diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index dc72a4e4fd9..5fb7b5dccc5 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -82,7 +82,7 @@ class Import::BitbucketServerController < Import::BaseController # rubocop: disable CodeReuse/ActiveRecord def filter_added_projects(import_type, import_sources) - current_user.created_projects.where(import_type: import_type, import_source: import_sources).includes(:import_state) + current_user.created_projects.where(import_type: import_type, import_source: import_sources).with_import_state end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb index 7ba8b3ce938..9aec870c6ea 100644 --- a/app/controllers/import/manifest_controller.rb +++ b/app/controllers/import/manifest_controller.rb @@ -87,7 +87,7 @@ class Import::ManifestController < Import::BaseController group.all_projects .where(import_type: 'manifest') .where(creator_id: current_user) - .includes(:import_state) + .with_import_state end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index bbf0bdd3662..2c3e60d12b7 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,8 +8,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include Gitlab::Experimentation::ControllerConcern include InitializesCurrentUserMode - before_action :verify_user_oauth_applications_enabled, except: :index - before_action :authenticate_user! + # Defined by the `Doorkeeper::ApplicationsController` and is redundant as we call `authenticate_user!` below. Not + # defining or skipping this will result in a `403` response to all requests. + skip_before_action :authenticate_admin! + + prepend_before_action :verify_user_oauth_applications_enabled, except: :index + prepend_before_action :authenticate_user! before_action :add_gon_variables before_action :load_scopes, only: [:index, :create, :edit, :update] diff --git a/app/controllers/oauth/token_info_controller.rb b/app/controllers/oauth/token_info_controller.rb new file mode 100644 index 00000000000..492c24b53b1 --- /dev/null +++ b/app/controllers/oauth/token_info_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Oauth::TokenInfoController < Doorkeeper::TokenInfoController + def show + if doorkeeper_token && doorkeeper_token.accessible? + token_json = doorkeeper_token.as_json + + # maintain backwards compatibility + render json: token_json.merge( + 'scopes' => token_json[:scope], + 'expires_in_seconds' => token_json[:expires_in] + ), status: :ok + else + error = Doorkeeper::OAuth::ErrorResponse.new(name: :invalid_request) + response.headers.merge!(error.headers) + render json: error.body, status: error.status + end + end +end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index d295b64082c..064b2a2cc12 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -4,13 +4,14 @@ class Profiles::NotificationsController < Profiles::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def show @user = current_user - @group_notifications = current_user.notification_settings.for_groups.order(:id) + @group_notifications = current_user.notification_settings.preload_source_route.for_groups.order(:id) @group_notifications += GroupsFinder.new( current_user, all_available: false, exclude_group_ids: @group_notifications.select(:source_id) ).execute.map { |group| current_user.notification_settings_for(group, inherit: true) } @project_notifications = current_user.notification_settings.for_projects.order(:id) + .preload_source_route .select { |notification| current_user.can?(:read_project, notification.source) } @global_notification_setting = current_user.global_notification_setting end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 2166dd7dad7..1477d79c911 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :time_display_relative, :time_format_in_24h, :show_whitespace_in_diffs, + :tab_width, :sourcegraph_enabled, :render_whitespace_in_code ] diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb new file mode 100644 index 00000000000..1fe31863469 --- /dev/null +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Projects + module Alerting + class NotificationsController < Projects::ApplicationController + respond_to :json + + skip_before_action :verify_authenticity_token + skip_before_action :project + + prepend_before_action :repository, :project_without_auth + + def create + token = extract_alert_manager_token(request) + result = notify_service.execute(token) + + head(response_status(result)) + end + + private + + def project_without_auth + @project ||= Project + .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}") + end + + def extract_alert_manager_token(request) + Doorkeeper::OAuth::Token.from_bearer_authorization(request) + end + + def notify_service + Projects::Alerting::NotifyService + .new(project, current_user, notification_payload) + end + + def response_status(result) + return :ok if result.success? + + result.http_status + end + + def notification_payload + params.permit![:notification] + end + end + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 3cd14cf845f..01e5103198b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] + before_action only: :show do + push_frontend_feature_flag(:code_navigation, @project) + end + def new commit unless @repository.empty? end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 70c4b536854..5c49fa842a4 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -16,7 +16,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:prometheus_computed_alerts) end before_action do - push_frontend_feature_flag(:auto_stop_environments) + push_frontend_feature_flag(:auto_stop_environments, default_enabled: true) end after_action :expire_etag_cache, only: [:cancel_auto_stop] diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 88f739ce29e..b4b03e219ab 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -30,7 +30,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle service = ErrorTracking::IssueUpdateService.new(project, current_user, issue_update_params) result = service.execute - return if handle_errors(result) + return if render_errors(result) render json: { result: result @@ -47,7 +47,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle ) result = service.execute - return if handle_errors(result) + return if render_errors(result) render json: { errors: serialize_errors(result[:issues]), @@ -60,14 +60,14 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle service = ErrorTracking::IssueDetailsService.new(project, current_user, issue_details_params) result = service.execute - return if handle_errors(result) + return if render_errors(result) render json: { error: serialize_detailed_error(result[:issue]) } end - def handle_errors(result) + def render_errors(result) unless result[:status] == :success render json: { message: result[:message] }, status: result[:http_status] || :bad_request @@ -75,7 +75,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle end def list_issues_params - params.permit(:search_term, :sort, :cursor) + params.permit(:search_term, :sort, :cursor, :issue_status) end def issue_update_params diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb deleted file mode 100644 index 3f6e116a62b..00000000000 --- a/app/controllers/projects/git_http_client_controller.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -class Projects::GitHttpClientController < Projects::ApplicationController - include ActionController::HttpAuthentication::Basic - include KerberosSpnegoHelper - include Gitlab::Utils::StrongMemoize - - attr_reader :authentication_result, :redirected_path - - delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true - delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result - - alias_method :user, :actor - alias_method :authenticated_user, :actor - - # Git clients will not know what authenticity token to send along - skip_around_action :set_session_storage - skip_before_action :verify_authenticity_token - skip_before_action :repository - before_action :authenticate_user - - private - - def download_request? - raise NotImplementedError - end - - def upload_request? - raise NotImplementedError - end - - def authenticate_user - @authentication_result = Gitlab::Auth::Result.new - - if allow_basic_auth? && basic_auth_provided? - login, password = user_name_and_password(request) - - if handle_basic_authentication(login, password) - return # Allow access - end - elsif allow_kerberos_spnego_auth? && spnego_provided? - kerberos_user = find_kerberos_user - - if kerberos_user - @authentication_result = Gitlab::Auth::Result.new( - kerberos_user, nil, :kerberos, Gitlab::Auth.full_authentication_abilities) - - send_final_spnego_response - return # Allow access - end - elsif http_download_allowed? - - @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code]) - - return # Allow access - end - - send_challenges - render plain: "HTTP Basic: Access denied\n", status: :unauthorized - rescue Gitlab::Auth::MissingPersonalAccessTokenError - render_missing_personal_access_token - end - - def basic_auth_provided? - has_basic_credentials?(request) - end - - def send_challenges - challenges = [] - challenges << 'Basic realm="GitLab"' if allow_basic_auth? - challenges << spnego_challenge if allow_kerberos_spnego_auth? - headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? - end - - def project - parse_repo_path unless defined?(@project) - - @project - end - - def parse_repo_path - @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}") - end - - def render_missing_personal_access_token - render plain: "HTTP Basic: Access denied\n" \ - "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \ - "You can generate one at #{profile_personal_access_tokens_url}", - status: :unauthorized - end - - def repository - strong_memoize(:repository) do - repo_type.repository_for(project) - end - end - - def repo_type - parse_repo_path unless defined?(@repo_type) - - @repo_type - end - - def handle_basic_authentication(login, password) - @authentication_result = Gitlab::Auth.find_for_git_client( - login, password, project: project, ip: request.ip) - - @authentication_result.success? - end - - def ci? - authentication_result.ci?(project) - end - - def http_download_allowed? - Gitlab::ProtocolAccess.allowed?('http') && - download_request? && - project && Guest.can?(:download_code, project) - end -end - -Projects::GitHttpClientController.prepend_if_ee('EE::Projects::GitHttpClientController') diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb deleted file mode 100644 index 236f1b967de..00000000000 --- a/app/controllers/projects/git_http_controller.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -class Projects::GitHttpController < Projects::GitHttpClientController - include WorkhorseRequest - - before_action :access_check - prepend_before_action :deny_head_requests, only: [:info_refs] - - rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403_with_exception - rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception - rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422_with_exception - rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception - - # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) - # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) - def info_refs - log_user_activity if upload_pack? - - render_ok - end - - # POST /foo/bar.git/git-upload-pack (git pull) - def git_upload_pack - enqueue_fetch_statistics_update - - render_ok - end - - # POST /foo/bar.git/git-receive-pack" (git push) - def git_receive_pack - render_ok - end - - private - - def deny_head_requests - head :forbidden if request.head? - end - - def download_request? - upload_pack? - end - - def upload_pack? - git_command == 'git-upload-pack' - end - - def git_command - if action_name == 'info_refs' - params[:service] - else - action_name.dasherize - end - end - - def render_ok - set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name) - end - - def render_403_with_exception(exception) - render plain: exception.message, status: :forbidden - end - - def render_404_with_exception(exception) - render plain: exception.message, status: :not_found - end - - def render_422_with_exception(exception) - render plain: exception.message, status: :unprocessable_entity - end - - def render_503_with_exception(exception) - render plain: exception.message, status: :service_unavailable - end - - def enqueue_fetch_statistics_update - return if Gitlab::Database.read_only? - return if repo_type.wiki? - return unless project&.daily_statistics_enabled? - - ProjectDailyStatisticsWorker.perform_async(project.id) - end - - def access - @access ||= access_klass.new(access_actor, project, 'http', - authentication_abilities: authentication_abilities, - namespace_path: params[:namespace_id], - project_path: project_path, - redirected_path: redirected_path, - auth_result_type: auth_result_type) - end - - def access_actor - return user if user - return :ci if ci? - end - - def access_check - access.check(git_command, Gitlab::GitAccess::ANY) - @project ||= access.project - end - - def access_klass - @access_klass ||= repo_type.access_checker_class - end - - def project_path - @project_path ||= params[:project_id].sub(/\.git$/, '') - end - - def log_user_activity - Users::ActivityService.new(user).execute - end -end - -Projects::GitHttpController.prepend_if_ee('EE::Projects::GitHttpController') diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 0944d7b47bf..b14a1179d46 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,7 +44,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) - push_frontend_feature_flag(:issue_link_types, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -188,7 +187,7 @@ class Projects::IssuesController < Projects::ApplicationController def import_csv if uploader = UploadService.new(project, params[:file]).execute - ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) + ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.") else diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb deleted file mode 100644 index 1273c55b83a..00000000000 --- a/app/controllers/projects/lfs_api_controller.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -class Projects::LfsApiController < Projects::GitHttpClientController - include LfsRequest - include Gitlab::Utils::StrongMemoize - - LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream' - - skip_before_action :lfs_check_access!, only: [:deprecated] - before_action :lfs_check_batch_operation!, only: [:batch] - - def batch - unless objects.present? - render_lfs_not_found - return - end - - if download_request? - render json: { objects: download_objects! } - elsif upload_request? - render json: { objects: upload_objects! } - else - raise "Never reached" - end - end - - def deprecated - render( - json: { - message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'), - documentation_url: "#{Gitlab.config.gitlab.url}/help" - }, - status: :not_implemented - ) - end - - private - - def download_request? - params[:operation] == 'download' - end - - def upload_request? - params[:operation] == 'upload' - end - - # rubocop: disable CodeReuse/ActiveRecord - def existing_oids - @existing_oids ||= begin - project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def download_objects! - objects.each do |object| - if existing_oids.include?(object[:oid]) - object[:actions] = download_actions(object) - - if Guest.can?(:download_code, project) - object[:authenticated] = true - end - else - object[:error] = { - code: 404, - message: _("Object does not exist on the server or you don't have permissions to access it") - } - end - end - objects - end - - def upload_objects! - objects.each do |object| - object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid]) - end - objects - end - - def download_actions(object) - { - download: { - href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}", - header: { - Authorization: authorization_header - }.compact - } - } - end - - def upload_actions(object) - { - upload: { - href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", - header: { - Authorization: authorization_header, - # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This - # ensures that Workhorse can intercept the request. - 'Content-Type': LFS_TRANSFER_CONTENT_TYPE - }.compact - } - } - end - - def lfs_check_batch_operation! - if batch_operation_disallowed? - render( - json: { - message: lfs_read_only_message - }, - content_type: LfsRequest::CONTENT_TYPE, - status: :forbidden - ) - end - end - - # Overridden in EE - def batch_operation_disallowed? - upload_request? && Gitlab::Database.read_only? - end - - # Overridden in EE - def lfs_read_only_message - _('You cannot write to this read-only GitLab instance.') - end - - def authorization_header - strong_memoize(:authorization_header) do - lfs_auth_header || request.headers['Authorization'] - end - end - - def lfs_auth_header - return unless user.is_a?(User) - - Gitlab::LfsToken.new(user).basic_encoding - end -end - -Projects::LfsApiController.prepend_if_ee('EE::Projects::LfsApiController') diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb deleted file mode 100644 index 6aacb9d9a56..00000000000 --- a/app/controllers/projects/lfs_locks_api_controller.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -class Projects::LfsLocksApiController < Projects::GitHttpClientController - include LfsRequest - - def create - @result = Lfs::LockFileService.new(project, user, lfs_params).execute - - render_json(@result[:lock]) - end - - def unlock - @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute - - render_json(@result[:lock]) - end - - def index - @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute - - render_json(@result[:locks]) - end - - def verify - @result = Lfs::LocksFinderService.new(project, user, {}).execute - - ours, theirs = split_by_owner(@result[:locks]) - - render_json({ ours: ours, theirs: theirs }, false) - end - - private - - def render_json(data, process = true) - render json: build_payload(data, process), - content_type: LfsRequest::CONTENT_TYPE, - status: @result[:http_status] - end - - def build_payload(data, process) - data = LfsFileLockSerializer.new.represent(data) if process - - return data if @result[:status] == :success - - # When the locking failed due to an existent Lock, the existent record - # is returned in `@result[:lock]` - error_payload(@result[:message], @result[:lock] ? data : {}) - end - - def error_payload(message, custom_attrs = {}) - custom_attrs.merge({ - message: message, - documentation_url: help_url - }) - end - - def split_by_owner(locks) - groups = locks.partition { |lock| lock.user_id == user.id } - - groups.map! do |records| - LfsFileLockSerializer.new.represent(records, root: false) - end - end - - def download_request? - params[:action] == 'index' - end - - def upload_request? - %w(create unlock verify).include?(params[:action]) - end - - def lfs_params - params.permit(:id, :path, :force) - end -end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb deleted file mode 100644 index 013e01b82aa..00000000000 --- a/app/controllers/projects/lfs_storage_controller.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -class Projects::LfsStorageController < Projects::GitHttpClientController - include LfsRequest - include WorkhorseRequest - include SendFileUpload - - skip_before_action :verify_workhorse_api!, only: :download - - def download - lfs_object = LfsObject.find_by_oid(oid) - unless lfs_object && lfs_object.file.exists? - render_lfs_not_found - return - end - - send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" }) - end - - def upload_authorize - set_workhorse_internal_api_content_type - - authorized = LfsObjectUploader.workhorse_authorize(has_length: true) - authorized.merge!(LfsOid: oid, LfsSize: size) - - render json: authorized - end - - def upload_finalize - if store_file!(oid, size) - head 200 - else - render plain: 'Unprocessable entity', status: :unprocessable_entity - end - rescue ActiveRecord::RecordInvalid - render_lfs_forbidden - rescue UploadedFile::InvalidPathError - render_lfs_forbidden - rescue ObjectStorage::RemoteStoreError - render_lfs_forbidden - end - - private - - def download_request? - action_name == 'download' - end - - def upload_request? - %w[upload_authorize upload_finalize].include? action_name - end - - def oid - params[:oid].to_s - end - - def size - params[:size].to_i - end - - # rubocop: disable CodeReuse/ActiveRecord - def store_file!(oid, size) - object = LfsObject.find_by(oid: oid, size: size) - unless object&.file&.exists? - object = create_file!(oid, size) - end - - return unless object - - link_to_project!(object) - end - # rubocop: enable CodeReuse/ActiveRecord - - def create_file!(oid, size) - uploaded_file = UploadedFile.from_params( - params, :file, LfsObjectUploader.workhorse_local_upload_path) - return unless uploaded_file - - LfsObject.create!(oid: oid, size: size, file: uploaded_file) - end - - # rubocop: disable CodeReuse/ActiveRecord - def link_to_project!(object) - if object && !object.projects.exists?(storage_project.id) - object.lfs_objects_projects.create!(project: storage_project) - end - end - # rubocop: enable CodeReuse/ActiveRecord -end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index c0c8474232a..953b2ffeb0b 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -18,7 +18,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end def diffs_batch - return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project) + return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project, default_enabled: true) diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options) positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user) @@ -64,6 +64,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options = additional_attributes.merge(diff_view: diff_view) + if @merge_request.project.context_commits_enabled? + options[:context_commits] = @merge_request.context_commits + end + render json: DiffsSerializer.new(request).represent(diffs, options) end @@ -107,6 +111,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end + if Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref? + return CompareService.new(@project, @merge_request.merge_ref_head.sha) + .execute(@project, @merge_request.target_branch) + end + if @start_sha @merge_request_diff.compare_with(@start_sha) else diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 17025670488..c5f017efe8d 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -19,13 +19,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do - push_frontend_feature_flag(:diffs_batch_load, @project) + push_frontend_feature_flag(:diffs_batch_load, @project, default_enabled: true) push_frontend_feature_flag(:single_mr_diff_view, @project) + push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) end before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:async_mr_widget, @project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -45,7 +45,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def show close_merge_request_if_no_source_project - @merge_request.check_mergeability + @merge_request.check_mergeability(async: true) respond_to do |format| format.html do @@ -117,6 +117,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo } end + def context_commits + return render_404 unless project.context_commits_enabled? + + # Get commits from repository + # or from cache if already merged + commits = ContextCommitsFinder.new(project, @merge_request, { search: params[:search], limit: params[:limit], offset: params[:offset] }).execute + render json: CommitEntity.represent(commits, { type: :full, request: merge_request }) + end + def test_reports reports_response(@merge_request.compare_test_reports) end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 6a7e2b69652..ead839e8441 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -47,7 +47,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def play - job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) + job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) # rubocop:disable CodeReuse/Worker if job_id pipelines_link_start = "<a href=\"#{project_pipelines_path(@project)}\">" diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index a62eb94a3e4..6d902e099d9 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -179,6 +179,16 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def test_reports_count + return unless Feature.enabled?(:junit_pipeline_view, project) + + begin + render json: { total_count: pipeline.test_reports_count }.to_json + rescue Gitlab::Ci::Parsers::ParserError + render json: { total_count: 0 }.to_json + end + end + private def serialize_pipelines diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 7bd084458d1..109c8b7005f 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -8,27 +8,26 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] - # rubocop: disable CodeReuse/ActiveRecord def index @sort = params[:sort].presence || sort_value_name + + @skip_groups = @project.invited_group_ids + @skip_groups += @project.group.self_and_ancestors_ids if @project.group + @group_links = @project.project_group_links + @group_links = @group_links.search(params[:search]) if params[:search].present? - @skip_groups = @group_links.pluck(:group_id) - @skip_groups << @project.namespace_id unless @project.personal? - @skip_groups += @project.group.ancestors.pluck(:id) if @project.group + @project_members = MembersFinder.new(@project, current_user) + .execute(include_relations: requested_relations, params: params.merge(sort: @sort)) - @project_members = MembersFinder.new(@project, current_user).execute(include_relations: requested_relations) + @project_members = present_members(@project_members.page(params[:page])) - if params[:search].present? - @project_members = @project_members.joins(:user).merge(User.search(params[:search])) - @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) - end + @requesters = present_members( + AccessRequestsFinder.new(@project).execute(current_user) + ) - @project_members = present_members(@project_members.sort_by_attribute(@sort).page(params[:page])) - @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user)) @project_member = @project.project_members.new end - # rubocop: enable CodeReuse/ActiveRecord def import @projects = current_user.authorized_projects.order_id_desc diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 9405fd526ae..e524d1c29a2 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -7,21 +7,32 @@ module Projects before_action :ensure_root_container_repository!, only: [:index] def index - @images = project.container_repositories - track_event(:list_repositories) - respond_to do |format| format.html format.json do - render json: ContainerRepositoriesSerializer + @images = project.container_repositories + + track_event(:list_repositories) + + serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) - .represent(@images) + + if Feature.enabled?(:vue_container_registry_explorer) + render json: serializer.with_pagination(request, response).represent(@images) + else + render json: serializer.represent(@images) + end end end end + # The show action renders index to allow frontend routing to work on page refresh + def show + render :index + end + def destroy - DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) + DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker track_event(:delete_repository) respond_to do |format| diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index e572c56adf5..c42e3f6bdba 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -31,12 +31,7 @@ module Projects end def bulk_destroy - unless params[:ids].present? - head :bad_request - return - end - - tag_names = params[:ids] || [] + tag_names = params.require(:ids) || [] if tag_names.size > LIMIT head :bad_request return diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 08a57a9b146..7ad841d645d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -3,11 +3,12 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project, except: [:index] - before_action :release, only: %i[edit update] + before_action :release, only: %i[edit show update] before_action :authorize_read_release! before_action do push_frontend_feature_flag(:release_issue_summary, project) push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true) + push_frontend_feature_flag(:release_show_page, project) end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_read_release_evidence!, only: [:evidence] @@ -29,6 +30,16 @@ class Projects::ReleasesController < Projects::ApplicationController end end + def show + return render_404 unless Feature.enabled?(:release_show_page, project) + + respond_to do |format| + format.html do + render :show + end + end + end + protected def releases @@ -37,7 +48,9 @@ class Projects::ReleasesController < Projects::ApplicationController def edit respond_to do |format| - format.html { render 'edit' } + format.html do + render :edit + end end end diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 2ed29b937ad..d0fb814948f 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -21,6 +21,8 @@ class Projects::RepositoriesController < Projects::ApplicationController end def archive + return render_404 if html_request? + set_cache_headers return if archive_not_modified? @@ -81,7 +83,7 @@ class Projects::RepositoriesController < Projects::ApplicationController def assign_archive_vars if params[:id] - @ref, @filename = extract_ref(params[:id]) + @ref, @filename = extract_ref_and_filename(params[:id]) else @ref = params[:ref] @filename = nil @@ -89,6 +91,26 @@ class Projects::RepositoriesController < Projects::ApplicationController rescue InvalidPathError render_404 end + + # path can be of the form: + # master + # master/first.zip + # master/first/second.tar.gz + # master/first/second/third.zip + # + # In the archive case, we know that the last value is always the filename, so we + # do a greedy match to extract the ref. This avoid having to pull all ref names + # from Redis. + def extract_ref_and_filename(id) + path = id.strip + data = path.match(/(.*)\/(.*)/) + + if data + [data[1], data[2]] + else + [path, nil] + end + end end Projects::RepositoriesController.prepend_if_ee('EE::Projects::RepositoriesController') diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 4b0d001fca6..0b55414d390 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -8,11 +8,15 @@ module Projects def index respond_to do |format| format.json do - functions = finder.execute + functions = finder.execute.select do |function| + can?(@current_user, :read_cluster, function.cluster) + end + + serialized_functions = serialize_function(functions) render json: { knative_installed: finder.knative_installed, - functions: serialize_function(functions) + functions: serialized_functions }.to_json end @@ -23,11 +27,14 @@ module Projects end def show - @service = serialize_function(finder.service(params[:environment_id], params[:id])) - @prometheus = finder.has_prometheus?(params[:environment_id]) + function = finder.service(params[:environment_id], params[:id]) + return not_found unless function && can?(@current_user, :read_cluster, function.cluster) + @service = serialize_function(function) return not_found if @service.nil? + @prometheus = finder.has_prometheus?(params[:environment_id]) + respond_to do |format| format.json do render json: @service diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index daaca9e1268..c916140211e 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -8,6 +8,8 @@ class Projects::ServicesController < Projects::ApplicationController before_action :ensure_service_enabled before_action :service before_action :web_hook_logs, only: [:edit, :update] + before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update] + before_action :redirect_deprecated_prometheus_service, only: [:update] respond_to :html @@ -93,4 +95,16 @@ class Projects::ServicesController < Projects::ApplicationController .as_json(only: @service.json_fields) .merge(errors: @service.errors.as_json) end + + def redirect_deprecated_prometheus_service + redirect_to edit_project_service_path(project, @service) if @service.is_a?(::PrometheusService) && Feature.enabled?(:settings_operations_prometheus_service, project) + end + + def set_deprecation_notice_for_prometheus_service + return if !@service.is_a?(::PrometheusService) || !Feature.enabled?(:settings_operations_prometheus_service, project) + + operations_link_start = "<a href=\"#{project_settings_operations_path(project)}\">" + message = s_('PrometheusService|You can now manage your Prometheus settings on the %{operations_link_start}Operations%{operations_link_end} page. Fields on this page has been deprecated.') % { operations_link_start: operations_link_start, operations_link_end: "</a>" } + flash.now[:alert] = message.html_safe + end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 6af815b8daa..bf0c2d885f8 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -69,7 +69,9 @@ module Projects return end + # rubocop:disable CodeReuse/Worker CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + # rubocop:enable CodeReuse/Worker pipelines_link_start = '<a href="%{url}">'.html_safe % { url: project_pipelines_path(@project) } flash[:toast] = _("A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>".html_safe } diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 1571cb8cd34..12b4f9ac56c 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -19,19 +19,36 @@ module Projects # overridden in EE def track_events(result) + if result[:status] == :success + ::Gitlab::Tracking::IncidentManagement.track_from_params( + update_params[:incident_management_setting_attributes] + ) + end end private - # overridden in EE def render_update_response(result) respond_to do |format| + format.html do + render_update_html_response(result) + end + format.json do render_update_json_response(result) end end end + def render_update_html_response(result) + if result[:status] == :success + flash[:notice] = _('Your changes have been saved') + redirect_to project_settings_operations_path(@project) + else + render 'show' + end + end + def render_update_json_response(result) if result[:status] == :success flash[:notice] = _('Your changes have been saved') @@ -60,7 +77,9 @@ module Projects # overridden in EE def permitted_project_params - { + project_params = { + incident_management_setting_attributes: ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys, + metrics_setting_attributes: [:external_dashboard_url], error_tracking_setting_attributes: [ @@ -72,6 +91,12 @@ module Projects grafana_integration_attributes: [:token, :grafana_url, :enabled] } + + if Feature.enabled?(:settings_operations_prometheus_service, project) + project_params[:prometheus_integration_attributes] = [:manual_configuration, :api_url] + end + + project_params end end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 0c634bbea03..63f5d5073a7 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -25,7 +25,7 @@ module Projects result = Projects::UpdateService.new(project, current_user, cleanup_params).execute if result[:status] == :success - RepositoryCleanupWorker.perform_async(project.id, current_user.id) + RepositoryCleanupWorker.perform_async(project.id, current_user.id) # rubocop:disable CodeReuse/Worker flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.') else flash[:alert] = _('Failed to upload object map file') diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index daddd9dd485..b9c7468890b 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -15,21 +15,25 @@ class Projects::SnippetsController < Projects::ApplicationController before_action :check_snippets_available! before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam] - # Allow read any snippet - before_action :authorize_read_project_snippet!, except: [:new, :create, :index] + # Allow create snippet + before_action :authorize_create_snippet!, only: [:new, :create] - # Allow write(create) snippet - before_action :authorize_create_project_snippet!, only: [:new, :create] + # Allow read any snippet + before_action :authorize_read_snippet!, except: [:new, :create, :index] # Allow modify snippet - before_action :authorize_update_project_snippet!, only: [:edit, :update] + before_action :authorize_update_snippet!, only: [:edit, :update] # Allow destroy snippet - before_action :authorize_admin_project_snippet!, only: [:destroy] + before_action :authorize_admin_snippet!, only: [:destroy] respond_to :html def index + @snippet_counts = Snippets::CountService + .new(current_user, project: @project) + .execute + @snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope]) .execute .page(params[:page]) @@ -115,16 +119,16 @@ class Projects::SnippetsController < Projects::ApplicationController project_snippet_path(@project, @snippet) end - def authorize_read_project_snippet! - return render_404 unless can?(current_user, :read_project_snippet, @snippet) + def authorize_read_snippet! + return render_404 unless can?(current_user, :read_snippet, @snippet) end - def authorize_update_project_snippet! - return render_404 unless can?(current_user, :update_project_snippet, @snippet) + def authorize_update_snippet! + return render_404 unless can?(current_user, :update_snippet, @snippet) end - def authorize_admin_project_snippet! - return render_404 unless can?(current_user, :admin_project_snippet, @snippet) + def authorize_admin_snippet! + return render_404 unless can?(current_user, :admin_snippet, @snippet) end def snippet_params diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index aba28e5c835..b8fe2a47b30 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -32,7 +32,7 @@ class Projects::TreeController < Projects::ApplicationController respond_to do |format| format.html do - lfs_blob_ids if Feature.disabled?(:vue_file_list, @project) + lfs_blob_ids if Feature.disabled?(:vue_file_list, @project, default_enabled: true) @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d39a4c373ff..31b86946ca2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -296,7 +296,7 @@ class ProjectsController < Projects::ApplicationController private def show_blob_ids? - repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project) + repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project, default_enabled: true) end # Render project landing depending of which features are available diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index c0ba87bf3ed..1c6cbf72cfa 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -13,6 +13,7 @@ class RegistrationsController < Devise::RegistrationsController before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? } + before_action :load_recaptcha, only: :new def new if experiment_enabled?(:signup_flow) @@ -35,7 +36,7 @@ class RegistrationsController < Devise::RegistrationsController end # Do not show the signed_up notice message when the signup_flow experiment is enabled. - # Instead, show it after succesfully updating the role. + # Instead, show it after successfully updating the role. flash[:notice] = nil if experiment_enabled?(:signup_flow) rescue Gitlab::Access::AccessDeniedError redirect_to(new_user_session_path) @@ -53,10 +54,7 @@ class RegistrationsController < Devise::RegistrationsController def welcome return redirect_to new_user_registration_path unless current_user - return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? && !current_user.setup_for_company.nil? - - current_user.name = nil if current_user.name == current_user.username - render layout: 'devise_experimental_separate_sign_up_flow' + return redirect_to stored_location_or_dashboard(current_user) if current_user.role.present? && !current_user.setup_for_company.nil? end def update_registration @@ -66,9 +64,9 @@ class RegistrationsController < Devise::RegistrationsController if result[:status] == :success track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group set_flash_message! :notice, :signed_up - redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) + redirect_to stored_location_or_dashboard(current_user) else - render :welcome, layout: 'devise_experimental_separate_sign_up_flow' + render :welcome end end @@ -113,12 +111,14 @@ class RegistrationsController < Devise::RegistrationsController return users_sign_up_welcome_path if experiment_enabled?(:signup_flow) - stored_location_or_dashboard_or_almost_there_path(user) + stored_location_or_dashboard(user) end def after_inactive_sign_up_path_for(resource) + # With the current `allow_unconfirmed_access_for` Devise setting in config/initializers/8_devise.rb, + # this method is never called. Leaving this here in case that value is set to 0. Gitlab::AppLogger.info(user_created_message) - Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path + users_almost_there_path end private @@ -140,7 +140,6 @@ class RegistrationsController < Devise::RegistrationsController ensure_correct_params! return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however - return if experiment_enabled?(:signup_flow) # when the experimental signup flow is enabled for the current user, disable the reCAPTCHA check return unless show_recaptcha_sign_up? return unless Gitlab::Recaptcha.load_configurations! @@ -181,16 +180,12 @@ class RegistrationsController < Devise::RegistrationsController Gitlab::Utils.to_boolean(params[:terms_opt_in]) end - def confirmed_or_unconfirmed_access_allowed(user) - user.confirmed? || Feature.enabled?(:soft_email_confirmation) || experiment_enabled?(:signup_flow) - end - def stored_location_or_dashboard(user) stored_location_for(user) || dashboard_projects_path end - def stored_location_or_dashboard_or_almost_there_path(user) - confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path + def load_recaptcha + Gitlab::Recaptcha.load_configurations! end # Part of an experiment to build a new sign up flow. Will be resolved diff --git a/app/controllers/repositories/application_controller.rb b/app/controllers/repositories/application_controller.rb new file mode 100644 index 00000000000..528cc310038 --- /dev/null +++ b/app/controllers/repositories/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Repositories + class ApplicationController < ::ApplicationController + skip_before_action :authenticate_user! + end +end diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb new file mode 100644 index 00000000000..76eb7c67205 --- /dev/null +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Repositories + class GitHttpClientController < Repositories::ApplicationController + include ActionController::HttpAuthentication::Basic + include KerberosSpnegoHelper + include Gitlab::Utils::StrongMemoize + + attr_reader :authentication_result, :redirected_path + + delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true + delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result + + alias_method :user, :actor + alias_method :authenticated_user, :actor + + # Git clients will not know what authenticity token to send along + skip_around_action :set_session_storage + skip_before_action :verify_authenticity_token + + before_action :parse_repo_path + before_action :authenticate_user + + private + + def download_request? + raise NotImplementedError + end + + def upload_request? + raise NotImplementedError + end + + def authenticate_user + @authentication_result = Gitlab::Auth::Result.new + + if allow_basic_auth? && basic_auth_provided? + login, password = user_name_and_password(request) + + if handle_basic_authentication(login, password) + return # Allow access + end + elsif allow_kerberos_spnego_auth? && spnego_provided? + kerberos_user = find_kerberos_user + + if kerberos_user + @authentication_result = Gitlab::Auth::Result.new( + kerberos_user, nil, :kerberos, Gitlab::Auth.full_authentication_abilities) + + send_final_spnego_response + return # Allow access + end + elsif http_download_allowed? + + @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code]) + + return # Allow access + end + + send_challenges + render plain: "HTTP Basic: Access denied\n", status: :unauthorized + rescue Gitlab::Auth::MissingPersonalAccessTokenError + render_missing_personal_access_token + end + + def basic_auth_provided? + has_basic_credentials?(request) + end + + def send_challenges + challenges = [] + challenges << 'Basic realm="GitLab"' if allow_basic_auth? + challenges << spnego_challenge if allow_kerberos_spnego_auth? + headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? + end + + def project + parse_repo_path unless defined?(@project) + + @project + end + + def parse_repo_path + @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:repository_id]}") + end + + def render_missing_personal_access_token + render plain: "HTTP Basic: Access denied\n" \ + "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \ + "You can generate one at #{profile_personal_access_tokens_url}", + status: :unauthorized + end + + def repository + strong_memoize(:repository) do + repo_type.repository_for(project) + end + end + + def repo_type + parse_repo_path unless defined?(@repo_type) + + @repo_type + end + + def handle_basic_authentication(login, password) + @authentication_result = Gitlab::Auth.find_for_git_client( + login, password, project: project, ip: request.ip) + + @authentication_result.success? + end + + def ci? + authentication_result.ci?(project) + end + + def http_download_allowed? + Gitlab::ProtocolAccess.allowed?('http') && + download_request? && + project && Guest.can?(:download_code, project) + end + end +end + +Repositories::GitHttpClientController.prepend_if_ee('EE::Repositories::GitHttpClientController') diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb new file mode 100644 index 00000000000..75c79881264 --- /dev/null +++ b/app/controllers/repositories/git_http_controller.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Repositories + class GitHttpController < Repositories::GitHttpClientController + include WorkhorseRequest + + before_action :access_check + prepend_before_action :deny_head_requests, only: [:info_refs] + + rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403_with_exception + rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception + rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422_with_exception + rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception + + # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) + # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) + def info_refs + log_user_activity if upload_pack? + + render_ok + end + + # POST /foo/bar.git/git-upload-pack (git pull) + def git_upload_pack + enqueue_fetch_statistics_update + + render_ok + end + + # POST /foo/bar.git/git-receive-pack" (git push) + def git_receive_pack + render_ok + end + + private + + def deny_head_requests + head :forbidden if request.head? + end + + def download_request? + upload_pack? + end + + def upload_pack? + git_command == 'git-upload-pack' + end + + def git_command + if action_name == 'info_refs' + params[:service] + else + action_name.dasherize + end + end + + def render_ok + set_workhorse_internal_api_content_type + render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name) + end + + def render_403_with_exception(exception) + render plain: exception.message, status: :forbidden + end + + def render_404_with_exception(exception) + render plain: exception.message, status: :not_found + end + + def render_422_with_exception(exception) + render plain: exception.message, status: :unprocessable_entity + end + + def render_503_with_exception(exception) + render plain: exception.message, status: :service_unavailable + end + + def enqueue_fetch_statistics_update + return if Gitlab::Database.read_only? + return unless repo_type.project? + return unless project&.daily_statistics_enabled? + + ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker + end + + def access + @access ||= access_klass.new(access_actor, project, 'http', + authentication_abilities: authentication_abilities, + namespace_path: params[:namespace_id], + project_path: project_path, + redirected_path: redirected_path, + auth_result_type: auth_result_type) + end + + def access_actor + return user if user + return :ci if ci? + end + + def access_check + access.check(git_command, Gitlab::GitAccess::ANY) + @project ||= access.project + end + + def access_klass + @access_klass ||= repo_type.access_checker_class + end + + def project_path + @project_path ||= params[:repository_id].sub(/\.git$/, '') + end + + def log_user_activity + Users::ActivityService.new(user).execute + end + end +end + +Repositories::GitHttpController.prepend_if_ee('EE::Repositories::GitHttpController') diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb new file mode 100644 index 00000000000..b1e0d1848d7 --- /dev/null +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module Repositories + class LfsApiController < Repositories::GitHttpClientController + include LfsRequest + include Gitlab::Utils::StrongMemoize + + LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream' + + skip_before_action :lfs_check_access!, only: [:deprecated] + before_action :lfs_check_batch_operation!, only: [:batch] + + def batch + unless objects.present? + render_lfs_not_found + return + end + + if download_request? + render json: { objects: download_objects! } + elsif upload_request? + render json: { objects: upload_objects! } + else + raise "Never reached" + end + end + + def deprecated + render( + json: { + message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'), + documentation_url: "#{Gitlab.config.gitlab.url}/help" + }, + status: :not_implemented + ) + end + + private + + def download_request? + params[:operation] == 'download' + end + + def upload_request? + params[:operation] == 'upload' + end + + # rubocop: disable CodeReuse/ActiveRecord + def existing_oids + @existing_oids ||= begin + project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def download_objects! + objects.each do |object| + if existing_oids.include?(object[:oid]) + object[:actions] = download_actions(object) + + if Guest.can?(:download_code, project) + object[:authenticated] = true + end + else + object[:error] = { + code: 404, + message: _("Object does not exist on the server or you don't have permissions to access it") + } + end + end + objects + end + + def upload_objects! + objects.each do |object| + object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid]) + end + objects + end + + def download_actions(object) + { + download: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}", + header: { + Authorization: authorization_header + }.compact + } + } + end + + def upload_actions(object) + { + upload: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", + header: { + Authorization: authorization_header, + # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This + # ensures that Workhorse can intercept the request. + 'Content-Type': LFS_TRANSFER_CONTENT_TYPE + }.compact + } + } + end + + def lfs_check_batch_operation! + if batch_operation_disallowed? + render( + json: { + message: lfs_read_only_message + }, + content_type: LfsRequest::CONTENT_TYPE, + status: :forbidden + ) + end + end + + # Overridden in EE + def batch_operation_disallowed? + upload_request? && Gitlab::Database.read_only? + end + + # Overridden in EE + def lfs_read_only_message + _('You cannot write to this read-only GitLab instance.') + end + + def authorization_header + strong_memoize(:authorization_header) do + lfs_auth_header || request.headers['Authorization'] + end + end + + def lfs_auth_header + return unless user.is_a?(User) + + Gitlab::LfsToken.new(user).basic_encoding + end + end +end + +Repositories::LfsApiController.prepend_if_ee('EE::Repositories::LfsApiController') diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb new file mode 100644 index 00000000000..19fc09ad4de --- /dev/null +++ b/app/controllers/repositories/lfs_locks_api_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Repositories + class LfsLocksApiController < Repositories::GitHttpClientController + include LfsRequest + + def create + @result = Lfs::LockFileService.new(project, user, lfs_params).execute + + render_json(@result[:lock]) + end + + def unlock + @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute + + render_json(@result[:lock]) + end + + def index + @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute + + render_json(@result[:locks]) + end + + def verify + @result = Lfs::LocksFinderService.new(project, user, {}).execute + + ours, theirs = split_by_owner(@result[:locks]) + + render_json({ ours: ours, theirs: theirs }, false) + end + + private + + def render_json(data, process = true) + render json: build_payload(data, process), + content_type: LfsRequest::CONTENT_TYPE, + status: @result[:http_status] + end + + def build_payload(data, process) + data = LfsFileLockSerializer.new.represent(data) if process + + return data if @result[:status] == :success + + # When the locking failed due to an existent Lock, the existent record + # is returned in `@result[:lock]` + error_payload(@result[:message], @result[:lock] ? data : {}) + end + + def error_payload(message, custom_attrs = {}) + custom_attrs.merge({ + message: message, + documentation_url: help_url + }) + end + + def split_by_owner(locks) + groups = locks.partition { |lock| lock.user_id == user.id } + + groups.map! do |records| + LfsFileLockSerializer.new.represent(records, root: false) + end + end + + def download_request? + params[:action] == 'index' + end + + def upload_request? + %w(create unlock verify).include?(params[:action]) + end + + def lfs_params + params.permit(:id, :path, :force) + end + end +end diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb new file mode 100644 index 00000000000..ec5ca5bbeec --- /dev/null +++ b/app/controllers/repositories/lfs_storage_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Repositories + class LfsStorageController < Repositories::GitHttpClientController + include LfsRequest + include WorkhorseRequest + include SendFileUpload + + skip_before_action :verify_workhorse_api!, only: :download + + def download + lfs_object = LfsObject.find_by_oid(oid) + unless lfs_object && lfs_object.file.exists? + render_lfs_not_found + return + end + + send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" }) + end + + def upload_authorize + set_workhorse_internal_api_content_type + + authorized = LfsObjectUploader.workhorse_authorize(has_length: true) + authorized.merge!(LfsOid: oid, LfsSize: size) + + render json: authorized + end + + def upload_finalize + if store_file!(oid, size) + head 200 + else + render plain: 'Unprocessable entity', status: :unprocessable_entity + end + rescue ActiveRecord::RecordInvalid + render_lfs_forbidden + rescue UploadedFile::InvalidPathError + render_lfs_forbidden + rescue ObjectStorage::RemoteStoreError + render_lfs_forbidden + end + + private + + def download_request? + action_name == 'download' + end + + def upload_request? + %w[upload_authorize upload_finalize].include? action_name + end + + def oid + params[:oid].to_s + end + + def size + params[:size].to_i + end + + # rubocop: disable CodeReuse/ActiveRecord + def store_file!(oid, size) + object = LfsObject.find_by(oid: oid, size: size) + unless object&.file&.exists? + object = create_file!(oid, size) + end + + return unless object + + link_to_project!(object) + end + # rubocop: enable CodeReuse/ActiveRecord + + def create_file!(oid, size) + uploaded_file = UploadedFile.from_params( + params, :file, LfsObjectUploader.workhorse_local_upload_path) + return unless uploaded_file + + LfsObject.create!(oid: oid, size: size, file: uploaded_file) + end + + def link_to_project!(object) + return unless object + + LfsObjectsProject.safe_find_or_create_by!( + project: project, + lfs_object: object + ) + end + end +end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 5b06f4f4b51..24452f9a188 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -52,7 +52,7 @@ class RootController < Dashboard::ProjectsController end def redirect_to_home_page_url? - # If user is not signed-in and tries to access root_path - redirect him to landing page + # If user is not signed-in and tries to access root_path - redirect them to landing page # Don't redirect to the default URL to prevent endless redirections return false unless Gitlab::CurrentSettings.home_page_url.present? diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 893f5145e99..20134de81a0 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -6,14 +6,24 @@ class SentNotificationsController < ApplicationController def unsubscribe @sent_notification = SentNotification.for(params[:id]) - return render_404 unless @sent_notification && @sent_notification.unsubscribable? + return render_404 unless unsubscribe_prerequisites_met? + return unsubscribe_and_redirect if current_user || params[:force] end private + def unsubscribe_prerequisites_met? + @sent_notification.present? && + @sent_notification.unsubscribable? && + noteable.present? + end + + def noteable + @sent_notification.noteable + end + def unsubscribe_and_redirect - noteable = @sent_notification.noteable noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project) flash[:notice] = _("You have been unsubscribed from this thread.") diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 551b37cb3d3..a7e8ef0798b 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -33,7 +33,7 @@ class Snippets::NotesController < ApplicationController end def authorize_read_snippet! - return render_404 unless can?(current_user, :read_personal_snippet, snippet) + return render_404 unless can?(current_user, :read_snippet, snippet) end def authorize_create_note! diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index fc073e47368..b6ad5fd02b0 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -126,7 +126,7 @@ class SnippetsController < ApplicationController end def authorize_read_snippet! - return if can?(current_user, :read_personal_snippet, @snippet) + return if can?(current_user, :read_snippet, @snippet) if current_user render_404 @@ -136,15 +136,15 @@ class SnippetsController < ApplicationController end def authorize_update_snippet! - return render_404 unless can?(current_user, :update_personal_snippet, @snippet) + return render_404 unless can?(current_user, :update_snippet, @snippet) end def authorize_admin_snippet! - return render_404 unless can?(current_user, :admin_personal_snippet, @snippet) + return render_404 unless can?(current_user, :admin_snippet, @snippet) end def authorize_create_snippet! - return render_404 unless can?(current_user, :create_personal_snippet) + return render_404 unless can?(current_user, :create_snippet) end def snippet_params diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 67d33648470..0b092d2622b 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -41,6 +41,8 @@ class UploadsController < ApplicationController case model when Note can?(current_user, :read_project, model.project) + when Snippet, ProjectSnippet + can?(current_user, :read_snippet, model) when User # We validate the current user has enough (writing) # access to itself when a secret is given. diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb index ebf1dd8ca02..4ee75218db1 100644 --- a/app/controllers/user_callouts_controller.rb +++ b/app/controllers/user_callouts_controller.rb @@ -2,7 +2,10 @@ class UserCalloutsController < ApplicationController def create - if ensure_callout.persisted? + callout = ensure_callout + + if callout.persisted? + callout.update(dismissed_at: Time.now) respond_to do |format| format.json { head :ok } end diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb index 06ebb286086..a55fb58a1bc 100644 --- a/app/finders/concerns/finder_with_cross_project_access.rb +++ b/app/finders/concerns/finder_with_cross_project_access.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Module to prepend into finders to specify wether or not the finder requires +# Module to prepend into finders to specify whether or not the finder requires # cross project access # # This module depends on the finder implementing the following methods: diff --git a/app/finders/concerns/time_frame_filter.rb b/app/finders/concerns/time_frame_filter.rb new file mode 100644 index 00000000000..e0baba25b64 --- /dev/null +++ b/app/finders/concerns/time_frame_filter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module TimeFrameFilter + def by_timeframe(items) + return items unless params[:start_date] && params[:start_date] + + start_date = params[:start_date].to_date + end_date = params[:end_date].to_date + + items.within_timeframe(start_date, end_date) + rescue ArgumentError + items + end +end diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb new file mode 100644 index 00000000000..f1b3eb43e84 --- /dev/null +++ b/app/finders/context_commits_finder.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ContextCommitsFinder + def initialize(project, merge_request, params = {}) + @project = project + @merge_request = merge_request + @search = params[:search] + @limit = (params[:limit] || 40).to_i + @offset = (params[:offset] || 0).to_i + end + + def execute + commits = init_collection + commits = filter_existing_commits(commits) + + commits + end + + private + + attr_reader :project, :merge_request, :search, :limit, :offset + + def init_collection + commits = + if search.present? + search_commits + else + project.repository.commits(merge_request.source_branch, { limit: limit, offset: offset }) + end + + commits + end + + def filter_existing_commits(commits) + commits.select! { |commit| already_included_ids.exclude?(commit.id) } + + commits + end + + def search_commits + key = search.strip + commits = [] + if Commit.valid_hash?(key) + mr_existing_commits_ids = merge_request.commits.map(&:id) + if mr_existing_commits_ids.exclude? key + commit_by_sha = project.repository.commit(key) + commits = [commit_by_sha] if commit_by_sha + end + else + commits = project.repository.find_commits_by_message(search, nil, nil, 20) + end + + commits + end + + def already_included_ids + mr_existing_commits_ids = merge_request.commits.map(&:id) + mr_context_commits_ids = merge_request.context_commits.map(&:id) + + mr_existing_commits_ids + mr_context_commits_ids + end +end diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index f8c7f0c3167..a351d30229e 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -12,16 +12,14 @@ class ContributedProjectsFinder < UnionFinder # visible by this user. # # Returns an ActiveRecord::Relation. - # rubocop: disable CodeReuse/ActiveRecord def execute(current_user = nil) # Do not show contributed projects if the user profile is private. return Project.none unless can_read_profile?(current_user) segments = all_projects(current_user) - find_union(segments, Project).includes(:namespace).order_id_desc + find_union(segments, Project).with_namespace.order_id_desc end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 6d059e10d05..7755cbdf9e5 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -43,16 +43,17 @@ class EventsFinder events = sort(events) events = events.with_associations if params[:with_associations] - paginated_filtered_by_user_visibility(events) end private def get_events - return EventCollection.new(current_user.authorized_projects).all_project_events if scope == 'all' - - source.events + if current_user && scope == 'all' + EventCollection.new(current_user.authorized_projects).all_project_events + else + source.events + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 194d7da1cab..6d5b1ca3bc5 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -314,18 +314,21 @@ class IssuableFinder params[:assignee_username].present? end - # rubocop: disable CodeReuse/ActiveRecord def assignee - return @assignee if defined?(@assignee) + assignees.first + end - @assignee = + # rubocop: disable CodeReuse/ActiveRecord + def assignees + strong_memoize(:assignees) do if assignee_id? - User.find_by(id: params[:assignee_id]) + User.where(id: params[:assignee_id]) elsif assignee_username? - User.find_by_username(params[:assignee_username]) + User.where(username: params[:assignee_username]) else - nil + User.none end + end end # rubocop: enable CodeReuse/ActiveRecord @@ -415,7 +418,7 @@ class IssuableFinder # These are "helper" params that are required inside the NOT to get the right results. They usually come in # at the top-level params, but if they do come in inside the `:not` params, they should take precedence. not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS)) - not_param = { key => value }.with_indifferent_access.merge(not_helpers) + not_param = { key => value }.with_indifferent_access.merge(not_helpers).merge(not_query: true) items_to_negate = self.class.new(current_user, not_param).execute @@ -543,6 +546,8 @@ class IssuableFinder # rubocop: enable CodeReuse/ActiveRecord def by_assignee(items) + return items.assigned_to(assignees) if not_query? && assignees.any? + if filter_by_no_assignee? items.unassigned elsif filter_by_any_assignee? @@ -624,7 +629,7 @@ class IssuableFinder elsif filter_by_any_label? items.any_label else - items.with_label(label_names, params[:sort]) + items.with_label(label_names, params[:sort], not_query: not_query?) end items @@ -673,4 +678,8 @@ class IssuableFinder def min_access_level ProjectFeature.required_minimum_access_level(klass) end + + def not_query? + !!params[:not_query] + end end diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb index 6fd914c88cd..0263d809246 100644 --- a/app/finders/keys_finder.rb +++ b/app/finders/keys_finder.rb @@ -8,16 +8,13 @@ class KeysFinder 'md5' => 'fingerprint' }.freeze - def initialize(current_user, params) - @current_user = current_user + def initialize(params) @params = params end def execute - raise GitLabAccessDeniedError unless current_user.admin? - keys = by_key_type - keys = by_user(keys) + keys = by_users(keys) keys = sort(keys) by_fingerprint(keys) @@ -25,7 +22,7 @@ class KeysFinder private - attr_reader :current_user, :params + attr_reader :params def by_key_type if params[:key_type] == 'ssh' @@ -39,10 +36,10 @@ class KeysFinder keys.order_last_used_at_desc end - def by_user(keys) - return keys unless params[:user] + def by_users(keys) + return keys unless params[:users] - keys.for_user(params[:user]) + keys.for_user(params[:users]) end def by_fingerprint(keys) diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index a919ff5bf8a..0617f34dc8c 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class MembersFinder - attr_reader :project, :current_user, :group + # Params can be any of the following: + # sort: string + # search: string def initialize(project, current_user) @project = project @@ -9,28 +11,39 @@ class MembersFinder @group = project.group end - def execute(include_relations: [:inherited, :direct]) + def execute(include_relations: [:inherited, :direct], params: {}) + members = find_members(include_relations, params) + + filter_members(members, params) + end + + def can?(*args) + Ability.allowed?(*args) + end + + private + + attr_reader :project, :current_user, :group + + def find_members(include_relations, params) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) return project_members if include_relations == [:direct] union_members = group_union_members(include_relations) - union_members << project_members if include_relations.include?(:direct) - if union_members.any? - distinct_union_of_members(union_members) - else - project_members - end - end + return project_members unless union_members.any? - def can?(*args) - Ability.allowed?(*args) + distinct_union_of_members(union_members) end - private + def filter_members(members, params) + members = members.search(params[:search]) if params[:search].present? + members = members.sort_by_attribute(params[:sort]) if params[:sort].present? + members + end def group_union_members(include_relations) [].tap do |members| diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb index 77b55cbb838..cfe648d9f79 100644 --- a/app/finders/milestones_finder.rb +++ b/app/finders/milestones_finder.rb @@ -11,6 +11,7 @@ class MilestonesFinder include FinderMethods + include TimeFrameFilter attr_reader :params @@ -24,6 +25,7 @@ class MilestonesFinder items = by_title(items) items = by_search_title(items) items = by_state(items) + items = by_timeframe(items) order(items) end diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb index 20f5b221a89..e7094d73905 100644 --- a/app/finders/personal_projects_finder.rb +++ b/app/finders/personal_projects_finder.rb @@ -17,15 +17,13 @@ class PersonalProjectsFinder < UnionFinder # min_access_level: integer # # Returns an ActiveRecord::Relation. - # rubocop: disable CodeReuse/ActiveRecord def execute(current_user = nil) return Project.none unless can?(current_user, :read_user_profile, @user) segments = all_projects(current_user) - find_union(segments, Project).includes(:namespace).order_updated_desc + find_union(segments, Project).with_namespace.order_updated_desc end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 48da44123f6..0599daab564 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -39,7 +39,7 @@ class PipelinesFinder # rubocop: disable CodeReuse/ActiveRecord def from_ids(ids) - pipelines.unscoped.where(id: ids) + pipelines.unscoped.where(project_id: project.id, id: ids) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/projects/prometheus/alerts_finder.rb b/app/finders/projects/prometheus/alerts_finder.rb new file mode 100644 index 00000000000..3e3b72647c5 --- /dev/null +++ b/app/finders/projects/prometheus/alerts_finder.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Projects + module Prometheus + # Find Prometheus alerts by +project+, +environment+, +id+, + # or any combo thereof. + # + # Optionally filter by +metric+. + # + # Arguments: + # params: + # project: Project | integer + # environment: Environment | integer + # metric: PrometheusMetric | integer + class AlertsFinder + def initialize(params = {}) + unless params[:project] || params[:environment] || params[:id] + raise ArgumentError, + 'Please provide one or more of the following params: :project, :environment, :id' + end + + @params = params + end + + # Find all matching alerts + # + # @return [ActiveRecord::Relation<PrometheusAlert>] + def execute + relation = by_project(PrometheusAlert) + relation = by_environment(relation) + relation = by_metric(relation) + relation = by_id(relation) + relation = ordered(relation) + + relation + end + + private + + attr_reader :params + + def by_project(relation) + return relation unless params[:project] + + relation.for_project(params[:project]) + end + + def by_environment(relation) + return relation unless params[:environment] + + relation.for_environment(params[:environment]) + end + + def by_metric(relation) + return relation unless params[:metric] + + relation.for_metric(params[:metric]) + end + + def by_id(relation) + return relation unless params[:id] + + relation.id_in(params[:id]) + end + + def ordered(relation) + relation.order_by('id_asc') + end + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index e8c50ef1a88..3b4ecbb5387 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -4,9 +4,15 @@ module Projects module Serverless class FunctionsFinder include Gitlab::Utils::StrongMemoize + include ReactiveCaching attr_reader :project + self.reactive_cache_key = ->(finder) { finder.cache_key } + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + MAX_CLUSTERS = 10 + def initialize(project) @project = project end @@ -15,8 +21,9 @@ module Projects knative_services.flatten.compact end - # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE def knative_installed + return knative_installed_from_cluster?(*cache_key) if available_environments.empty? + states = services_finders.map do |finder| finder.knative_detected.tap do |state| return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks @@ -45,32 +52,73 @@ module Projects end end + def self.from_cache(project_id) + project = Project.find(project_id) + + new(project) + end + + def cache_key(*args) + [project.id] + end + + def calculate_reactive_cache(*) + # rubocop: disable CodeReuse/ActiveRecord + project.all_clusters.enabled.take(MAX_CLUSTERS).any? do |cluster| + cluster.kubeclient.knative_client.discover + rescue Kubeclient::ResourceNotFoundError + next + end + end + private + def knative_installed_from_cluster?(*cache_key) + cached_data = with_reactive_cache_memoized(*cache_key) { |data| data } + + return ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] if cached_data.nil? + + cached_data ? true : false + end + + def with_reactive_cache_memoized(*cache_key) + strong_memoize(:reactive_cache) do + with_reactive_cache(*cache_key) { |data| data } + end + end + def knative_service(environment_scope, name) finders_for_scope(environment_scope).map do |finder| services = finder .services .select { |svc| svc["metadata"]["name"] == name } - add_metadata(finder, services).first unless services.nil? + attributes = add_metadata(finder, services).first + next unless attributes + + Gitlab::Serverless::Service.new(attributes) end end def knative_services services_finders.map do |finder| - services = finder.services + attributes = add_metadata(finder, finder.services) - add_metadata(finder, services) unless services.nil? + attributes&.map do |attributes| + Gitlab::Serverless::Service.new(attributes) + end end end def add_metadata(finder, services) + return if services.nil? + add_pod_count = services.one? services.each do |s| s["environment_scope"] = finder.cluster.environment_scope - s["cluster_id"] = finder.cluster.id + s["environment"] = finder.environment + s["cluster"] = finder.cluster if add_pod_count s["podcount"] = finder @@ -95,6 +143,10 @@ module Projects environment_scope == finder.cluster.environment_scope end end + + def id + nil + end end end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index ac18c17dc61..c319d2fed87 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -44,6 +44,8 @@ class ProjectsFinder < UnionFinder init_collection end + use_cte = params.delete(:use_cte) + collection = Project.wrap_with_cte(collection) if use_cte collection = filter_projects(collection) sort(collection) end @@ -177,7 +179,7 @@ class ProjectsFinder < UnionFinder end def sort(items) - params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc + params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc end def by_archived(projects) diff --git a/app/finders/protected_branches_finder.rb b/app/finders/protected_branches_finder.rb new file mode 100644 index 00000000000..68e8d2a9f54 --- /dev/null +++ b/app/finders/protected_branches_finder.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# ProtectedBranchesFinder +# +# Used to filter protected branches by set of params +# +# Arguments: +# project - which project to scope to +# params: +# search: string +class ProtectedBranchesFinder + LIMIT = 100 + + attr_accessor :project, :params + + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + protected_branches = project.limited_protected_branches(LIMIT) + protected_branches = by_name(protected_branches) + + protected_branches + end + + private + + def by_name(protected_branches) + return protected_branches unless params[:search].present? + + protected_branches.by_name(params[:search]) + end +end diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb new file mode 100644 index 00000000000..119bc51e4a4 --- /dev/null +++ b/app/graphql/mutations/issues/update.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class Update < Base + graphql_name 'UpdateIssue' + + # Add arguments here instead of creating separate mutations + + def resolve(project_path:, iid:, **args) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + + ::Issues::UpdateService.new(project, current_user, args).execute(issue) + + { + issue: issue, + errors: issue.errors.full_messages + } + end + end + end +end + +Mutations::Issues::Update.prepend_if_ee('::EE::Mutations::Issues::Update') diff --git a/app/graphql/mutations/notes/update.rb b/app/graphql/mutations/notes/update.rb deleted file mode 100644 index ebf57b800c0..00000000000 --- a/app/graphql/mutations/notes/update.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Notes - class Update < Base - graphql_name 'UpdateNote' - - authorize :admin_note - - argument :id, - GraphQL::ID_TYPE, - required: true, - description: 'The global id of the note to update' - - argument :body, - GraphQL::STRING_TYPE, - required: true, - description: copy_field_description(Types::Notes::NoteType, :body) - - def resolve(args) - note = authorized_find!(id: args[:id]) - - check_object_is_note!(note) - - note = ::Notes::UpdateService.new( - note.project, - current_user, - { note: args[:body] } - ).execute(note) - - { - note: note.reset, - errors: errors_on_object(note) - } - end - end - end -end diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb new file mode 100644 index 00000000000..9a53337f253 --- /dev/null +++ b/app/graphql/mutations/notes/update/base.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Update + # This is a Base class for the Note update mutations and is not + # mounted as a GraphQL mutation itself. + class Base < Mutations::Notes::Base + authorize :admin_note + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the note to update' + + def resolve(args) + note = authorized_find!(id: args[:id]) + + pre_update_checks!(note, args) + + updated_note = ::Notes::UpdateService.new( + note.project, + current_user, + note_params(note, args) + ).execute(note) + + # It's possible for updated_note to be `nil`, in the situation + # where the note is deleted within `Notes::UpdateService` due to + # the body of the note only containing Quick Actions. + { + note: updated_note&.reset, + errors: updated_note ? errors_on_object(updated_note) : [] + } + end + + private + + def pre_update_checks!(_note, _args) + raise NotImplementedError + end + + def note_params(_note, args) + { note: args[:body] }.compact + end + end + end + end +end diff --git a/app/graphql/mutations/notes/update/image_diff_note.rb b/app/graphql/mutations/notes/update/image_diff_note.rb new file mode 100644 index 00000000000..7aad3af1e04 --- /dev/null +++ b/app/graphql/mutations/notes/update/image_diff_note.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Update + class ImageDiffNote < Mutations::Notes::Update::Base + graphql_name 'UpdateImageDiffNote' + + argument :body, + GraphQL::STRING_TYPE, + required: false, + description: copy_field_description(Types::Notes::NoteType, :body) + + argument :position, + Types::Notes::UpdateDiffImagePositionInputType, + required: false, + description: copy_field_description(Types::Notes::NoteType, :position) + + def ready?(**args) + # As both arguments are optional, validate here that one of the + # arguments are present. + # + # This may be able to be done using InputUnions in the future + # if this RFC is merged: + # https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md + if args.values_at(:body, :position).compact.blank? + raise Gitlab::Graphql::Errors::ArgumentError, + 'body or position arguments are required' + end + + super(args) + end + + private + + def pre_update_checks!(note, args) + unless note.is_a?(DiffNote) && note.position.on_image? + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + 'Resource is not an ImageDiffNote' + end + end + + def note_params(note, args) + super(note, args).merge( + position: position_params(note, args) + ).compact + end + + def position_params(note, args) + new_position = args[:position]&.to_h&.compact + return unless new_position + + original_position = note.position.to_h + + Gitlab::Diff::Position.new(original_position.merge(new_position)) + end + end + end + end +end diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb new file mode 100644 index 00000000000..03a174fc8d9 --- /dev/null +++ b/app/graphql/mutations/notes/update/note.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module Notes + module Update + class Note < Mutations::Notes::Update::Base + graphql_name 'UpdateNote' + + argument :body, + GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::Notes::NoteType, :body) + + private + + def pre_update_checks!(note, _args) + check_object_is_note!(note) + end + end + end + end +end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 4e0e65d09a9..266a123de82 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -67,11 +67,11 @@ module Mutations end def authorized_resource?(project) - Ability.allowed?(context[:current_user], :create_project_snippet, project) + Ability.allowed?(context[:current_user], :create_snippet, project) end def can_create_personal_snippet? - Ability.allowed?(context[:current_user], :create_personal_snippet) + Ability.allowed?(context[:current_user], :create_snippet) end end end diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb new file mode 100644 index 00000000000..8a6265207cd --- /dev/null +++ b/app/graphql/mutations/todos/restore_many.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class RestoreMany < ::Mutations::Todos::Base + graphql_name 'TodoRestoreMany' + + MAX_UPDATE_AMOUNT = 50 + + argument :ids, + [GraphQL::ID_TYPE], + required: true, + description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)' + + field :updated_ids, [GraphQL::ID_TYPE], + null: false, + description: 'The ids of the updated todo items' + + def resolve(ids:) + check_update_amount_limit!(ids) + + todos = authorized_find_all_pending_by_current_user(model_ids_of(ids)) + updated_ids = restore(todos) + + { + updated_ids: gids_of(updated_ids), + errors: errors_on_objects(todos) + } + end + + private + + def gids_of(ids) + ids.map { |id| ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s } + end + + def model_ids_of(ids) + ids.map do |gid| + parsed_gid = ::URI::GID.parse(gid) + parsed_gid.model_id.to_i if accessible_todo?(parsed_gid) + end.compact + end + + def accessible_todo?(gid) + gid.app == GlobalID.app && todo?(gid) + end + + def todo?(gid) + GlobalID.parse(gid)&.model_class&.ancestors&.include?(Todo) + end + + def raise_too_many_todos_requested_error + raise Gitlab::Graphql::Errors::ArgumentError, 'Too many todos requested.' + end + + def check_update_amount_limit!(ids) + raise_too_many_todos_requested_error if ids.size > MAX_UPDATE_AMOUNT + end + + def errors_on_objects(todos) + todos.flat_map { |todo| errors_on_object(todo) } + end + + def authorized_find_all_pending_by_current_user(ids) + return Todo.none if ids.blank? || current_user.nil? + + Todo.for_ids(ids).for_user(current_user).done + end + + def restore(todos) + TodoService.new.mark_todos_as_pending(todos, current_user) + end + end + end +end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index f2b015edfa1..66cb224f157 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -58,5 +58,9 @@ module Resolvers def single? false end + + def current_user + context[:current_user] + end end end diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb new file mode 100644 index 00000000000..45c03bf0bef --- /dev/null +++ b/app/graphql/resolvers/boards_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + class BoardsResolver < BaseResolver + type Types::BoardType, null: true + + def resolve(**args) + # The project or group could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project/group to query for boards, so + # make sure it's loaded and not `nil` before continuing. + parent = object.respond_to?(:sync) ? object.sync : object + + return Board.none unless parent + + Boards::ListService.new(parent, context[:current_user]).execute(create_default_board: false) + end + end +end diff --git a/app/graphql/resolvers/concerns/time_frame_arguments.rb b/app/graphql/resolvers/concerns/time_frame_arguments.rb new file mode 100644 index 00000000000..ef333dd05a5 --- /dev/null +++ b/app/graphql/resolvers/concerns/time_frame_arguments.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module TimeFrameArguments + extend ActiveSupport::Concern + + included do + argument :start_date, Types::TimeType, + required: false, + description: 'List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)' + + argument :end_date, Types::TimeType, + required: false, + description: 'List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)' + end + + def validate_timeframe_params!(args) + return unless args[:start_date].present? || args[:end_date].present? + + error_message = + if args[:start_date].nil? || args[:end_date].nil? + "Both startDate and endDate must be present." + elsif args[:start_date] > args[:end_date] + "startDate is after endDate" + end + + if error_message + raise Gitlab::Graphql::Errors::ArgumentError, error_message + end + end +end diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb index 63455ff3acb..72c5c19c25c 100644 --- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb @@ -8,7 +8,6 @@ module Resolvers description: 'ID of the Sentry issue' def resolve(**args) - project = object current_user = context[:current_user] issue_id = GlobalID.parse(args[:id]).model_id @@ -23,6 +22,14 @@ module Resolvers issue end + + private + + def project + return object.gitlab_project if object.respond_to?(:gitlab_project) + + object + end end end end diff --git a/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb new file mode 100644 index 00000000000..e4b4854c273 --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryErrorCollectionResolver < BaseResolver + def resolve(**args) + project = object + + service = ::ErrorTracking::ListIssuesService.new( + project, + context[:current_user] + ) + + Gitlab::ErrorTracking::ErrorCollection.new( + external_url: service.external_url, + project: project + ) + end + end + end +end diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb new file mode 100644 index 00000000000..f5356660569 --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryErrorStackTraceResolver < BaseResolver + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'ID of the Sentry issue' + + def resolve(**args) + issue_id = GlobalID.parse(args[:id]).model_id + + # Get data from Sentry + response = ::ErrorTracking::IssueLatestEventService.new( + project, + current_user, + { issue_id: issue_id } + ).execute + + event = response[:latest_event] + event.gitlab_project = project if event + + event + end + + private + + def project + return object.gitlab_project if object.respond_to?(:gitlab_project) + + object + end + end + end +end diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb new file mode 100644 index 00000000000..79f99709505 --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryErrorsResolver < BaseResolver + def resolve(**args) + args[:cursor] = args.delete(:after) + project = object.project + + result = ::ErrorTracking::ListIssuesService.new( + project, + context[:current_user], + args + ).execute + + next_cursor = result[:pagination]&.dig('next', 'cursor') + previous_cursor = result[:pagination]&.dig('previous', 'cursor') + issues = result[:issues] + + # ReactiveCache is still fetching data + return if issues.nil? + + Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues) + end + end + end +end diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb new file mode 100644 index 00000000000..2e7b6fdfd5f --- /dev/null +++ b/app/graphql/resolvers/milestone_resolver.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Resolvers + class MilestoneResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include TimeFrameArguments + + argument :state, Types::MilestoneStateEnum, + required: false, + description: 'Filter milestones by state' + + type Types::MilestoneType, null: true + + def resolve(**args) + validate_timeframe_params!(args) + + authorize! + + MilestonesFinder.new(milestones_finder_params(args)).execute + end + + private + + def milestones_finder_params(args) + { + state: args[:state] || 'all', + start_date: args[:start_date], + end_date: args[:end_date] + }.merge(parent_id_parameter) + end + + def parent + @parent ||= object.respond_to?(:sync) ? object.sync : object + end + + def parent_id_parameter + if parent.is_a?(Group) + { group_ids: parent.id } + elsif parent.is_a?(Project) + { project_ids: parent.id } + end + end + + # MilestonesFinder does not check for current_user permissions, + # so for now we need to keep it here. + def authorize! + Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! + end + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index efeee4a7a4d..3ade1300c2d 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -10,6 +10,8 @@ module Types @calls_gitaly = !!kwargs.delete(:calls_gitaly) @constant_complexity = !!kwargs[:complexity] kwargs[:complexity] ||= field_complexity(kwargs[:resolver_class]) + @feature_flag = kwargs[:feature_flag] + kwargs = check_feature_flag(kwargs) super(*args, **kwargs, &block) end @@ -28,8 +30,27 @@ module Types @constant_complexity end + def visible?(context) + return false if feature_flag.present? && !Feature.enabled?(feature_flag) + + super + end + private + attr_reader :feature_flag + + def feature_documentation_message(key, description) + "#{description}. Available only when feature flag #{key} is enabled." + end + + def check_feature_flag(args) + args[:description] = feature_documentation_message(args[:feature_flag], args[:description]) if args[:feature_flag].present? + args.delete(:feature_flag) + + args + end + def field_complexity(resolver_class) if resolver_class field_resolver_complexity diff --git a/app/graphql/types/blob_viewers/type_enum.rb b/app/graphql/types/blob_viewers/type_enum.rb new file mode 100644 index 00000000000..35e659197e5 --- /dev/null +++ b/app/graphql/types/blob_viewers/type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module BlobViewers + class TypeEnum < BaseEnum + graphql_name 'BlobViewersType' + description 'Types of blob viewers' + + value 'rich', value: :rich + value 'simple', value: :simple + value 'auxiliary', value: :auxiliary + end + end +end diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb new file mode 100644 index 00000000000..9c95a987fe4 --- /dev/null +++ b/app/graphql/types/board_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class BoardType < BaseObject + graphql_name 'Board' + description 'Represents a project or group board' + + authorize :read_board + + field :id, type: GraphQL::ID_TYPE, null: false, + description: 'ID (global ID) of the board' + field :name, type: GraphQL::STRING_TYPE, null: true, + description: 'Name of the board' + end +end + +Types::BoardType.prepend_if_ee('::EE::Types::BoardType') diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index 87f84ec576f..eb25e3651a8 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -12,7 +12,7 @@ module Types description: 'ID (global ID) of the commit' field :sha, type: GraphQL::STRING_TYPE, null: false, description: 'SHA1 ID of the commit' - field :title, type: GraphQL::STRING_TYPE, null: true, + field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, description: 'Title of the commit message' field :description, type: GraphQL::STRING_TYPE, null: true, description: 'Description of the commit message' @@ -26,6 +26,11 @@ module Types description: 'Rendered HTML of the commit signature' field :author_name, type: GraphQL::STRING_TYPE, null: true, description: 'Commit authors name' + field :author_gravatar, type: GraphQL::STRING_TYPE, null: true, + description: 'Commit authors gravatar', + resolve: -> (commit, args, context) do + GravatarService.new.execute(commit.author_email, 40) + end # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, @@ -40,7 +45,7 @@ module Types type: Types::Ci::PipelineType, null: true, description: "Latest pipeline of the commit", - deprecation_reason: 'use pipelines', + deprecation_reason: 'Use pipelines', resolver: Resolvers::CommitPipelinesResolver.last end end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index af6d8818d90..124398f28e7 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -4,96 +4,95 @@ module Types module ErrorTracking class SentryDetailedErrorType < ::Types::BaseObject graphql_name 'SentryDetailedError' + description 'A Sentry error.' - present_using SentryDetailedErrorPresenter + present_using SentryErrorPresenter authorize :read_sentry_issue field :id, GraphQL::ID_TYPE, null: false, - description: "ID (global ID) of the error" + description: 'ID (global ID) of the error' field :sentry_id, GraphQL::STRING_TYPE, method: :id, null: false, - description: "ID (Sentry ID) of the error" + description: 'ID (Sentry ID) of the error' field :title, GraphQL::STRING_TYPE, null: false, - description: "Title of the error" + description: 'Title of the error' field :type, GraphQL::STRING_TYPE, null: false, - description: "Type of the error" + description: 'Type of the error' field :user_count, GraphQL::INT_TYPE, null: false, - description: "Count of users affected by the error" + description: 'Count of users affected by the error' field :count, GraphQL::INT_TYPE, null: false, - description: "Count of occurrences" + description: 'Count of occurrences' field :first_seen, Types::TimeType, null: false, - description: "Timestamp when the error was first seen" + description: 'Timestamp when the error was first seen' field :last_seen, Types::TimeType, null: false, - description: "Timestamp when the error was last seen" + description: 'Timestamp when the error was last seen' field :message, GraphQL::STRING_TYPE, null: true, - description: "Sentry metadata message of the error" + description: 'Sentry metadata message of the error' field :culprit, GraphQL::STRING_TYPE, null: false, - description: "Culprit of the error" + description: 'Culprit of the error' + field :external_base_url, GraphQL::STRING_TYPE, + null: false, + description: 'External Base URL of the Sentry Instance' field :external_url, GraphQL::STRING_TYPE, null: false, - description: "External URL of the error" + description: 'External URL of the error' field :sentry_project_id, GraphQL::ID_TYPE, method: :project_id, null: false, - description: "ID of the project (Sentry project)" + description: 'ID of the project (Sentry project)' field :sentry_project_name, GraphQL::STRING_TYPE, method: :project_name, null: false, - description: "Name of the project affected by the error" + description: 'Name of the project affected by the error' field :sentry_project_slug, GraphQL::STRING_TYPE, method: :project_slug, null: false, - description: "Slug of the project affected by the error" + description: 'Slug of the project affected by the error' field :short_id, GraphQL::STRING_TYPE, null: false, - description: "Short ID (Sentry ID) of the error" + description: 'Short ID (Sentry ID) of the error' field :status, Types::ErrorTracking::SentryErrorStatusEnum, null: false, - description: "Status of the error" + description: 'Status of the error' field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], null: false, - description: "Last 24hr stats of the error" + description: 'Last 24hr stats of the error' field :first_release_last_commit, GraphQL::STRING_TYPE, null: true, - description: "Commit the error was first seen" + description: 'Commit the error was first seen' field :last_release_last_commit, GraphQL::STRING_TYPE, null: true, - description: "Commit the error was last seen" + description: 'Commit the error was last seen' field :first_release_short_version, GraphQL::STRING_TYPE, null: true, - description: "Release version the error was first seen" + description: 'Release version the error was first seen' field :last_release_short_version, GraphQL::STRING_TYPE, null: true, - description: "Release version the error was last seen" + description: 'Release version the error was last seen' field :gitlab_commit, GraphQL::STRING_TYPE, null: true, - description: "GitLab commit SHA attributed to the Error based on the release version" + description: 'GitLab commit SHA attributed to the Error based on the release version' field :gitlab_commit_path, GraphQL::STRING_TYPE, null: true, - description: "Path to the GitLab page for the GitLab commit attributed to the error" - - def first_seen - DateTime.parse(object.first_seen) - end - - def last_seen - DateTime.parse(object.last_seen) - end - - def project_id - Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s - end + description: 'Path to the GitLab page for the GitLab commit attributed to the error' + field :gitlab_issue_path, GraphQL::STRING_TYPE, + method: :gitlab_issue, + null: true, + description: 'URL of GitLab Issue' + field :tags, Types::ErrorTracking::SentryErrorTagsType, + null: false, + description: 'Tags associated with the Sentry Error' end end end diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb new file mode 100644 index 00000000000..121146133cb --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + class SentryErrorCollectionType < ::Types::BaseObject + graphql_name 'SentryErrorCollection' + description 'An object containing a collection of Sentry errors, and a detailed error.' + + authorize :read_sentry_issue + + field :errors, + Types::ErrorTracking::SentryErrorType.connection_type, + connection: false, + null: true, + description: "Collection of Sentry Errors", + extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], + resolver: Resolvers::ErrorTracking::SentryErrorsResolver do + argument :search_term, + String, + description: 'Search term for the Sentry error.', + required: false + argument :sort, + String, + description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.', + required: false + end + field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType, + null: true, + description: 'Detailed version of a Sentry error on the project', + resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + field :error_stack_trace, Types::ErrorTracking::SentryErrorStackTraceType, + null: true, + description: 'Stack Trace of Sentry Error', + resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver + field :external_url, + GraphQL::STRING_TYPE, + null: true, + description: "External URL for Sentry" + end + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb new file mode 100644 index 00000000000..e6d02c948d5 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorStackTraceContextType < ::Types::BaseObject + graphql_name 'SentryErrorStackTraceContext' + description 'An object context for a Sentry error stack trace' + + field :line, + GraphQL::INT_TYPE, + null: false, + description: 'Line number of the context' + field :code, + GraphQL::STRING_TYPE, + null: false, + description: 'Code number of the context' + + def line + object[0] + end + + def code + object[1] + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb new file mode 100644 index 00000000000..0747e41e9fb --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorStackTraceEntryType < ::Types::BaseObject + graphql_name 'SentryErrorStackTraceEntry' + description 'An object containing a stack trace entry for a Sentry error.' + + field :function, GraphQL::STRING_TYPE, + null: true, + description: 'Function in which the Sentry error occurred' + field :col, GraphQL::STRING_TYPE, + null: true, + description: 'Function in which the Sentry error occurred' + field :line, GraphQL::STRING_TYPE, + null: true, + description: 'Function in which the Sentry error occurred' + field :file_name, GraphQL::STRING_TYPE, + null: true, + description: 'File in which the Sentry error occurred' + field :trace_context, [Types::ErrorTracking::SentryErrorStackTraceContextType], + null: true, + description: 'Context of the Sentry error' + + def function + object['function'] + end + + def col + object['colNo'] + end + + def line + object['lineNo'] + end + + def file_name + object['filename'] + end + + def trace_context + object['context'] + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb new file mode 100644 index 00000000000..0e6105d1ff2 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + class SentryErrorStackTraceType < ::Types::BaseObject + graphql_name 'SentryErrorStackTrace' + description 'An object containing a stack trace entry for a Sentry error.' + + authorize :read_sentry_issue + + field :issue_id, GraphQL::STRING_TYPE, + null: false, + description: 'ID of the Sentry error' + field :date_received, GraphQL::STRING_TYPE, + null: false, + description: 'Time the stack trace was received by Sentry' + field :stack_trace_entries, [Types::ErrorTracking::SentryErrorStackTraceEntryType], + null: false, + description: 'Stack trace entries for the Sentry error' + end + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_tags_type.rb b/app/graphql/types/error_tracking/sentry_error_tags_type.rb new file mode 100644 index 00000000000..e6d96571561 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_tags_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorTagsType < ::Types::BaseObject + graphql_name 'SentryErrorTags' + description 'State of a Sentry error' + + field :level, GraphQL::STRING_TYPE, + null: true, + description: "Severity level of the Sentry Error" + field :logger, GraphQL::STRING_TYPE, + null: true, + description: "Logger of the Sentry Error" + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb new file mode 100644 index 00000000000..7a842025e45 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_type.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorType < ::Types::BaseObject + graphql_name 'SentryError' + description 'A Sentry error. A simplified version of SentryDetailedError.' + + present_using SentryErrorPresenter + + field :id, GraphQL::ID_TYPE, + null: false, + description: 'ID (global ID) of the error' + field :sentry_id, GraphQL::STRING_TYPE, + method: :id, + null: false, + description: 'ID (Sentry ID) of the error' + field :first_seen, Types::TimeType, + null: false, + description: 'Timestamp when the error was first seen' + field :last_seen, Types::TimeType, + null: false, + description: 'Timestamp when the error was last seen' + field :title, GraphQL::STRING_TYPE, + null: false, + description: 'Title of the error' + field :type, GraphQL::STRING_TYPE, + null: false, + description: 'Type of the error' + field :user_count, GraphQL::INT_TYPE, + null: false, + description: 'Count of users affected by the error' + field :count, GraphQL::INT_TYPE, + null: false, + description: 'Count of occurrences' + field :message, GraphQL::STRING_TYPE, + null: true, + description: 'Sentry metadata message of the error' + field :culprit, GraphQL::STRING_TYPE, + null: false, + description: 'Culprit of the error' + field :external_url, GraphQL::STRING_TYPE, + null: false, + description: 'External URL of the error' + field :short_id, GraphQL::STRING_TYPE, + null: false, + description: 'Short ID (Sentry ID) of the error' + field :status, Types::ErrorTracking::SentryErrorStatusEnum, + null: false, + description: 'Status of the error' + field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], + null: false, + description: 'Last 24hr stats of the error' + field :sentry_project_id, GraphQL::ID_TYPE, + method: :project_id, + null: false, + description: 'ID of the project (Sentry project)' + field :sentry_project_name, GraphQL::STRING_TYPE, + method: :project_name, + null: false, + description: 'Name of the project affected by the error' + field :sentry_project_slug, GraphQL::STRING_TYPE, + method: :project_slug, + null: false, + description: 'Slug of the project affected by the error' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 393948fcede..718770ebfbc 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -17,12 +17,35 @@ module Types group.avatar_url(only_path: false) end + field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if sharing a project with another group within this group is prevented' + + field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str, + description: 'The permission level required to create projects in the group' + field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str, + description: 'The permission level required to create subgroups within the group' + + field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if all users in this group are required to set up two-factor authentication' + field :two_factor_grace_period, GraphQL::INT_TYPE, null: true, + description: 'Time before two-factor authentication is enforced' + + field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates whether Auto DevOps is enabled for all projects within this group' + + field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if a group has email notifications disabled' + field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if a group is disabled from getting mentioned' field :parent, GroupType, null: true, description: 'Parent group', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } + + field :milestones, Types::MilestoneType.connection_type, null: true, + description: 'Find milestones', + resolver: Resolvers::MilestoneResolver end end diff --git a/app/graphql/types/milestone_state_enum.rb b/app/graphql/types/milestone_state_enum.rb new file mode 100644 index 00000000000..032571ac88f --- /dev/null +++ b/app/graphql/types/milestone_state_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class MilestoneStateEnum < BaseEnum + value 'active' + value 'closed' + end +end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 9c3afb28674..900f8c6f01d 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -3,25 +3,36 @@ module Types class MilestoneType < BaseObject graphql_name 'Milestone' + description 'Represents a milestone.' + + present_using MilestonePresenter authorize :read_milestone field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the milestone' - field :description, GraphQL::STRING_TYPE, null: true, - description: 'Description of the milestone' + field :title, GraphQL::STRING_TYPE, null: false, description: 'Title of the milestone' - field :state, GraphQL::STRING_TYPE, null: false, + + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the milestone' + + field :state, Types::MilestoneStateEnum, null: false, description: 'State of the milestone' + field :web_path, GraphQL::STRING_TYPE, null: false, method: :milestone_path, + description: 'Web path of the milestone' + field :due_date, Types::TimeType, null: true, description: 'Timestamp of the milestone due date' + field :start_date, Types::TimeType, null: true, description: 'Timestamp of the milestone start date' field :created_at, Types::TimeType, null: false, description: 'Timestamp of milestone creation' + field :updated_at, Types::TimeType, null: false, description: 'Timestamp of last milestone update' end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 0a9c0143945..90e9e1ec0b9 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -4,13 +4,14 @@ module Types class MutationType < BaseObject include Gitlab::Graphql::MountMutation - graphql_name "Mutation" + graphql_name 'Mutation' mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetDueDate + mount_mutation Mutations::Issues::Update mount_mutation Mutations::MergeRequests::SetLabels mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone @@ -20,11 +21,19 @@ module Types mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true - mount_mutation Mutations::Notes::Update + mount_mutation Mutations::Notes::Update::Note, + description: 'Updates a Note. If the body of the Note contains only quick actions, ' \ + 'the Note will be destroyed during the update, and no Note will be ' \ + 'returned' + mount_mutation Mutations::Notes::Update::ImageDiffNote, + description: 'Updates a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`). ' \ + 'If the body of the Note contains only quick actions, the Note will be ' \ + 'destroyed during the update, and no Note will be returned' mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Todos::MarkDone mount_mutation Mutations::Todos::Restore mount_mutation Mutations::Todos::MarkAllDone + mount_mutation Mutations::Todos::RestoreMany mount_mutation Mutations::Snippets::Destroy mount_mutation Mutations::Snippets::Update mount_mutation Mutations::Snippets::Create diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index 654562da0a7..cc00feba2e6 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -29,10 +29,10 @@ module Types # Fields for image positions field :x, GraphQL::INT_TYPE, null: true, - description: 'X position on which the comment was made', + description: 'X position of the note', resolve: -> (position, _args, _ctx) { position.x if position.on_image? } field :y, GraphQL::INT_TYPE, null: true, - description: 'Y position on which the comment was made', + description: 'Y position of the note', resolve: -> (position, _args, _ctx) { position.y if position.on_image? } field :width, GraphQL::INT_TYPE, null: true, description: 'Total width of the image', diff --git a/app/graphql/types/notes/update_diff_image_position_input_type.rb b/app/graphql/types/notes/update_diff_image_position_input_type.rb new file mode 100644 index 00000000000..af99764f9f2 --- /dev/null +++ b/app/graphql/types/notes/update_diff_image_position_input_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module Notes + # InputType used for updateImageDiffNote mutation. + # + # rubocop: disable Graphql/AuthorizeTypes + class UpdateDiffImagePositionInputType < BaseInputObject + graphql_name 'UpdateDiffImagePositionInput' + + argument :x, GraphQL::INT_TYPE, + required: false, + description: copy_field_description(Types::Notes::DiffPositionType, :x) + + argument :y, GraphQL::INT_TYPE, + required: false, + description: copy_field_description(Types::Notes::DiffPositionType, :y) + + argument :width, GraphQL::INT_TYPE, + required: false, + description: copy_field_description(Types::Notes::DiffPositionType, :width) + + argument :height, GraphQL::INT_TYPE, + required: false, + description: copy_field_description(Types::Notes::DiffPositionType, :height) + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index 2879dbd2b5c..f773fce0c63 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -16,12 +16,13 @@ module Types :create_deployment, :push_to_delete_protected_branch, :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, - :create_pages, :destroy_pages, :read_pages_content, :admin_operations + :create_pages, :destroy_pages, :read_pages_content, :admin_operations, + :read_merge_request permission_field :create_snippet def create_snippet - Ability.allowed?(context[:current_user], :create_project_snippet, object) + Ability.allowed?(context[:current_user], :create_snippet, object) end end end diff --git a/app/graphql/types/permission_types/user.rb b/app/graphql/types/permission_types/user.rb index dba4de2dacc..93d9787d58e 100644 --- a/app/graphql/types/permission_types/user.rb +++ b/app/graphql/types/permission_types/user.rb @@ -8,7 +8,7 @@ module Types permission_field :create_snippet def create_snippet - Ability.allowed?(context[:current_user], :create_personal_snippet) + Ability.allowed?(context[:current_user], :create_snippet) end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5ece4926951..b44baa50955 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -173,6 +173,12 @@ module Types null: true, description: 'Snippets of the project', resolver: Resolvers::Projects::SnippetsResolver + + field :sentry_errors, + Types::ErrorTracking::SentryErrorCollectionType, + null: true, + description: 'Paginated collection of Sentry errors on the project', + resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 199a6226c6d..e8f6eeff3e9 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -40,3 +40,5 @@ module Types resolver: Resolvers::EchoResolver end end + +Types::QueryType.prepend_if_ee('EE::Types::QueryType') diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 3f780528945..c4d65174990 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -36,10 +36,6 @@ module Types description: 'File Name of the snippet', null: true - field :content, GraphQL::STRING_TYPE, - description: 'Content of the snippet', - null: false - field :description, GraphQL::STRING_TYPE, description: 'Description of the snippet', null: true @@ -64,6 +60,10 @@ module Types description: 'Raw URL of the snippet', null: false + field :blob, type: Types::Snippets::BlobType, + description: 'Snippet blob', + null: false + markdown_field :description_html, null: true, method: :description end end diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb new file mode 100644 index 00000000000..feff5d20874 --- /dev/null +++ b/app/graphql/types/snippets/blob_type.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Types + module Snippets + # rubocop: disable Graphql/AuthorizeTypes + class BlobType < BaseObject + graphql_name 'SnippetBlob' + description 'Represents the snippet blob' + present_using SnippetBlobPresenter + + field :rich_data, GraphQL::STRING_TYPE, + description: 'Blob highlighted data', + null: true + + field :plain_data, GraphQL::STRING_TYPE, + description: 'Blob plain highlighted data', + null: true + + field :raw_path, GraphQL::STRING_TYPE, + description: 'Blob raw content endpoint path', + null: false + + field :size, GraphQL::INT_TYPE, + description: 'Blob size', + null: false + + field :binary, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob is binary', + method: :binary?, + null: false + + field :name, GraphQL::STRING_TYPE, + description: 'Blob name', + null: true + + field :path, GraphQL::STRING_TYPE, + description: 'Blob path', + null: true + + field :simple_viewer, type: Types::Snippets::BlobViewerType, + description: 'Blob content simple viewer', + null: false + + field :rich_viewer, type: Types::Snippets::BlobViewerType, + description: 'Blob content rich viewer', + null: true + + field :mode, type: GraphQL::STRING_TYPE, + description: 'Blob mode', + null: true + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb new file mode 100644 index 00000000000..3e653576d07 --- /dev/null +++ b/app/graphql/types/snippets/blob_viewer_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Snippets + class BlobViewerType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'SnippetBlobViewer' + description 'Represents how the blob content should be displayed' + + field :type, Types::BlobViewers::TypeEnum, + description: 'Type of blob viewer', + null: false + + field :load_async, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob content is loaded async', + null: false + + field :collapsed, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob should be displayed collapsed', + method: :collapsed?, + null: false + + field :too_large, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob too large to be displayed', + method: :too_large?, + null: false + + field :render_error, GraphQL::STRING_TYPE, + description: 'Error rendering the blob content', + null: true + + field :file_type, GraphQL::STRING_TYPE, + description: 'Content file type', + method: :partial_name, + null: false + + field :loading_partial_name, GraphQL::STRING_TYPE, + description: 'Loading partial name', + null: false + end + end +end diff --git a/app/helpers/analytics_navbar_helper.rb b/app/helpers/analytics_navbar_helper.rb new file mode 100644 index 00000000000..021b9bb10cd --- /dev/null +++ b/app/helpers/analytics_navbar_helper.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module AnalyticsNavbarHelper + class NavbarSubItem + attr_reader :title, :path, :link, :link_to_options + + def initialize(title:, path:, link:, link_to_options: {}) + @title = title + @path = path + @link = link + @link_to_options = link_to_options.merge(title: title) + end + end + + def project_analytics_navbar_links(project, current_user) + [ + cycle_analytics_navbar_link(project, current_user), + repository_analytics_navbar_link(project, current_user), + ci_cd_analytics_navbar_link(project, current_user) + ].compact + end + + def group_analytics_navbar_links(group, current_user) + [] + end + + private + + def navbar_sub_item(args) + NavbarSubItem.new(args) + end + + def cycle_analytics_navbar_link(project, current_user) + return unless Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true) + return unless project_nav_tab?(:cycle_analytics) + + navbar_sub_item( + title: _('Value Stream Analytics'), + path: 'cycle_analytics#show', + link: project_cycle_analytics_path(project), + link_to_options: { class: 'shortcuts-project-cycle-analytics' } + ) + end + + def repository_analytics_navbar_link(project, current_user) + return if Feature.disabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true) + return if project.empty_repo? + + navbar_sub_item( + title: _('Repository Analytics'), + path: 'graphs#charts', + link: charts_project_graph_path(project, current_ref), + link_to_options: { class: 'shortcuts-repository-charts' } + ) + end + + def ci_cd_analytics_navbar_link(project, current_user) + return unless Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true) + return unless project_nav_tab?(:pipelines) + return unless project.feature_available?(:builds, current_user) || !project.empty_repo? + + navbar_sub_item( + title: _('CI / CD Analytics'), + path: 'pipelines#charts', + link: charts_project_pipelines_path(project) + ) + end +end + +AnalyticsNavbarHelper.prepend_if_ee('EE::AnalyticsNavbarHelper') diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 0e14db6ddbf..f96c26b428c 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -119,6 +119,17 @@ module ApplicationSettingsHelper options_for_select(options, selected) end + def repository_storages_options_json + options = Gitlab.config.repositories.storages.map do |name, storage| + { + label: "#{name} - #{storage['gitaly_address']}", + value: name + } + end + + options.to_json + end + def external_authorization_description _("If enabled, access to projects will be validated on an external service"\ " using their classification label.") @@ -351,10 +362,10 @@ module ApplicationSettingsHelper status_delete_self_monitoring_project_admin_application_settings_path, 'self_monitoring_project_exists' => - Gitlab::CurrentSettings.instance_administration_project.present?.to_s, + Gitlab::CurrentSettings.self_monitoring_project.present?.to_s, 'self_monitoring_project_full_path' => - Gitlab::CurrentSettings.instance_administration_project&.full_path + Gitlab::CurrentSettings.self_monitoring_project&.full_path } end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index a9c4cfe7dcc..e8d3d5f62cb 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -89,7 +89,17 @@ module AuthHelper def enabled_button_based_providers disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || [] - button_based_providers.map(&:to_s) - disabled_providers + providers = button_based_providers.map(&:to_s) - disabled_providers + providers.sort_by do |provider| + case provider + when 'google_oauth2' + 0 + when 'github' + 1 + else + 2 + end + end end def button_based_providers_enabled? diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 733d21daec1..68dbc5b65d1 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -122,6 +122,13 @@ module AvatarsHelper else source_identicon(source, options) end + + rescue GRPC::Unavailable, GRPC::DeadlineExceeded => e + # Handle Gitaly connection issues gracefully + Gitlab::ErrorTracking + .track_exception(e, source_type: source.class.name, source_id: source.id) + + source_identicon(source, options) end def source_identicon(source, options = {}) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index c9fb28d0299..77a320f8925 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -27,7 +27,7 @@ module BlobHelper "#{current_user.namespace.full_path}/#{project.path}" end - segments = [ide_path, 'project', project_path, 'edit', ref] + segments = [ide_path, 'project', project_path, 'edit', encode_ide_path(ref)] segments.concat(['-', encode_ide_path(path)]) if path.present? File.join(segments) end @@ -47,7 +47,7 @@ module BlobHelper def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) return unless blob = readable_blob(options, path, project, ref) - common_classes = "btn btn-primary js-edit-blob #{options[:extra_class]}" + common_classes = "btn btn-primary js-edit-blob ml-2 #{options[:extra_class]}" edit_button_tag(blob, common_classes, @@ -62,7 +62,7 @@ module BlobHelper return unless blob = readable_blob(options, path, project, ref) edit_button_tag(blob, - 'btn btn-inverted btn-primary ide-edit-button', + 'btn btn-inverted btn-primary ide-edit-button ml-2', _('Web IDE'), ide_edit_path(project, ref, path, options), project, diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index b95fd8800c0..34e65c322c6 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -6,19 +6,16 @@ module BroadcastMessagesHelper end def current_broadcast_notification_message - BroadcastMessage.current_notification_messages(request.path).last + not_hidden_messages = BroadcastMessage.current_notification_messages(request.path).select do |message| + cookies["hide_broadcast_notification_message_#{message.id}"].blank? + end + not_hidden_messages.last end def broadcast_message(message, opts = {}) return unless message.present? - classes = "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'}" - - content_tag :div, dir: 'auto', class: classes, style: broadcast_message_style(message) do - concat sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top') - concat ' ' - concat render_broadcast_message(message) - end + render "shared/broadcast_message", { message: message, opts: opts } end def broadcast_message_style(broadcast_message) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 610d823dd3c..e1aed5393ea 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -53,7 +53,7 @@ module ButtonHelper } content_tag :button, button_attributes do - concat(sprite_icon('duplicate')) unless hide_button_icon + concat(sprite_icon('copy-to-clipboard')) unless hide_button_icon concat(button_text) end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index f55acad8517..80bf765f3a4 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -17,17 +17,6 @@ module ClustersHelper end end - def new_cluster_partial(provider: nil) - case provider - when 'aws' - 'clusters/clusters/aws/new' - when 'gcp' - 'clusters/clusters/gcp/new' - else - 'clusters/clusters/cloud_providers/cloud_provider_selector' - end - end - def render_gcp_signup_offer return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? return unless show_gcp_signup_offer? diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d58f634425b..ace8bae03ac 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -18,7 +18,7 @@ module CommitsHelper end def commit_to_html(commit, ref, project) - render 'projects/commits/commit', + render 'projects/commits/commit.html', commit: commit, ref: ref, project: project diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 620a63fdc46..4c3c4931387 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -29,6 +29,8 @@ module DiffHelper if action_name == 'diff_for_path' options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) + elsif action_name == 'show' + options[:include_context_commits] = true unless @project.context_commits_enabled? end options diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 993c18f9229..fd330d4efd9 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module EnvironmentsHelper + include ActionView::Helpers::AssetUrlHelper prepend_if_ee('::EE::EnvironmentsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule def environments_list_data @@ -21,7 +22,7 @@ module EnvironmentsHelper { "settings-path" => edit_project_service_path(project, 'prometheus'), "clusters-path" => project_clusters_path(project), - "current-environment-name": environment.name, + "current-environment-name" => environment.name, "documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'), "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'), @@ -33,7 +34,6 @@ module EnvironmentsHelper "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), "default-branch" => project.default_branch, - "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), "has-metrics" => "#{environment.has_metrics?}", diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 62be591ec47..1b36f60c316 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -19,6 +19,18 @@ module ExploreHelper request_path_with_options(options) end + def filter_audit_path(options = {}) + exist_opts = { + entity_type: params[:entity_type], + entity_id: params[:entity_id], + created_before: params[:created_before], + created_after: params[:created_after], + sort: params[:sort] + } + options = exist_opts.merge(options).delete_if { |key, value| value.blank? } + request_path_with_options(options) + end + def filter_groups_path(options = {}) request_path_with_options(options) end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 6ddcbf61090..661197e84ae 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -7,7 +7,6 @@ module GroupsHelper groups#details groups#activity groups#subgroups - analytics#show ] end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index d6e466d4678..a0228c6bd94 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -4,7 +4,7 @@ require 'nokogiri' module MarkupHelper include ActionView::Helpers::TextHelper - include ::Gitlab::ActionViewOutput::Context + include ActionView::Context def plain?(filename) Gitlab::MarkupHelper.plain?(filename) @@ -76,13 +76,14 @@ module MarkupHelper # +max_chars+ limit. If the length limit falls within a tag's contents, then # the tag contents are truncated without removing the closing tag. def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) - md = markdown_field(object, attribute, options) + md = markdown_field(object, attribute, options.merge(post_process: false)) return unless md.present? tags = %w(a gl-emoji b pre code p span) tags << 'img' if options[:allow_images] text = truncate_visible(md, max_chars || md.length) + text = prepare_for_rendering(text, markdown_field_render_context(object, attribute, options)) text = sanitize( text, tags: tags, @@ -107,15 +108,12 @@ module MarkupHelper def markdown_field(object, field, context = {}) object = object.for_display if object.respond_to?(:for_display) - redacted_field_html = object.try(:"redacted_#{field}_html") - return '' unless object.present? - return redacted_field_html if redacted_field_html - html = Banzai.render_field(object, field, context) - context.reverse_merge!(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context) + redacted_field_html = object.try(:"redacted_#{field}_html") + return redacted_field_html if redacted_field_html - prepare_for_rendering(html, context) + render_markdown_field(object, field, context) end def markup(file_name, text, context = {}) @@ -155,7 +153,7 @@ module MarkupHelper other_markup_unsafe(file_name, text, context) end rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name, context: context) + Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name) simple_format(text) end @@ -277,6 +275,23 @@ module MarkupHelper Gitlab::OtherMarkup.render(file_name, text, context) end + def render_markdown_field(object, field, context = {}) + post_process = context.delete(:post_process) + post_process = true if post_process.nil? + + html = Banzai.render_field(object, field, context) + + return html unless post_process + + prepare_for_rendering(html, markdown_field_render_context(object, field, context)) + end + + def markdown_field_render_context(object, field, base_context = {}) + return base_context unless object.respond_to?(:banzai_render_context) + + base_context.reverse_merge(object.banzai_render_context(field)) + end + def prepare_for_rendering(html, context = {}) return '' unless html.present? diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 6a271e93cd9..8a79217c929 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -63,6 +63,10 @@ module PreferencesHelper Gitlab::ColorSchemes.for_user(current_user).css_class end + def user_tab_width + Gitlab::TabWidth.css_class_for_user(current_user) + end + def language_choices Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] } end diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index ed5c7640ec1..5be4f67bde8 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -22,8 +22,6 @@ module Projects::ErrorTrackingHelper { 'issue-id' => issue_id, 'project-path' => project.full_path, - 'list-path' => project_error_tracking_index_path(project), - 'issue-details-path' => details_project_error_tracking_index_path(*opts), 'issue-update-path' => update_project_error_tracking_index_path(*opts), 'project-issues-path' => project_issues_path(project), 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e2173140a08..023790f7d87 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -3,6 +3,11 @@ module ProjectsHelper prepend_if_ee('::EE::ProjectsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule + def project_incident_management_setting + @project_incident_management_setting ||= @project.incident_management_setting || + @project.build_incident_management_setting + end + def link_to_project(project) link_to namespace_project_path(namespace_id: project.namespace, id: project), title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') @@ -403,6 +408,10 @@ module ProjectsHelper nav_tabs << :operations end + if can?(current_user, :read_cycle_analytics, project) + nav_tabs << :cycle_analytics + end + tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab @@ -425,7 +434,7 @@ module ProjectsHelper { environments: :read_environment, milestones: :read_milestone, - snippets: :read_project_snippet, + snippets: :read_snippet, settings: :admin_project, builds: :read_build, clusters: :read_cluster, @@ -443,7 +452,7 @@ module ProjectsHelper blobs: :download_code, commits: :download_code, merge_requests: :read_merge_request, - notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet], + notes: [:read_merge_request, :download_code, :read_issue, :read_snippet], members: :read_project_member ) end @@ -643,7 +652,6 @@ module ProjectsHelper projects#show projects#activity releases#index - cycle_analytics#show ] end @@ -700,10 +708,19 @@ module ProjectsHelper end def vue_file_list_enabled? - Feature.enabled?(:vue_file_list, @project) + Feature.enabled?(:vue_file_list, @project, default_enabled: true) + end + + def native_code_navigation_enabled?(project) + Feature.enabled?(:code_navigation, project) end def show_visibility_confirm_modal?(project) project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0 end + + def settings_container_registry_expiration_policy_available?(project) + Gitlab.config.registry.enabled && + can?(current_user, :destroy_container_image, project) + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 9a5c5f274a0..e478f76818f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -86,19 +86,6 @@ module SearchHelper }).html_safe end - def find_project_for_result_blob(projects, result) - @project - end - - # Used in EE - def blob_projects(results) - nil - end - - def parse_search_result(result) - result - end - # Overriden in EE def search_blob_title(project, path) path diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb index 6326d98461e..07d83b8d850 100644 --- a/app/helpers/sidekiq_helper.rb +++ b/app/helpers/sidekiq_helper.rb @@ -5,7 +5,7 @@ module SidekiqHelper (?<pid>\d+)\s+ (?<cpu>[\d\.,]+)\s+ (?<mem>[\d\.,]+)\s+ - (?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+ + (?<state>[DIEKNRSTVWXZLpsl\+<>/\d]+)\s+ (?<start>.+?)\s+ (?<command>(?:ruby\d+:\s+)?sidekiq.*\].*) \z}x.freeze diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 33f3bb0b749..3e448087db0 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -207,6 +207,13 @@ module SortingHelper }.merge(issuable_sort_option_overrides) end + def audit_logs_sort_order_hash + { + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created + } + end + def issuable_sort_option_title(sort_value) sort_value = issuable_sort_option_overrides[sort_value] || sort_value diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 4b83988e8bb..32c613ab4ad 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -7,9 +7,7 @@ module SubmoduleHelper # links to files listing for submodule if submodule is a project on this server def submodule_links(submodule_item, ref = nil, repository = @repository) - url = repository.submodule_url_for(ref, submodule_item.path) - - submodule_links_for_url(submodule_item.id, url, repository) + repository.submodule_links.for(submodule_item, ref) end def submodule_links_for_url(submodule_item_id, url, repository) @@ -41,9 +39,9 @@ module SubmoduleHelper elsif relative_self_url?(url) relative_self_links(url, submodule_item_id, repository.project) elsif github_dot_com_url?(url) - standard_links('github.com', namespace, project, submodule_item_id) + github_com_tree_links(namespace, project, submodule_item_id) elsif gitlab_dot_com_url?(url) - standard_links('gitlab.com', namespace, project, submodule_item_id) + gitlab_com_tree_links(namespace, project, submodule_item_id) else [sanitize_submodule_url(url), nil] end @@ -75,8 +73,13 @@ module SubmoduleHelper url.start_with?('../', './') end - def standard_links(host, namespace, project, commit) - base = ['https://', host, '/', namespace, '/', project].join('') + def gitlab_com_tree_links(namespace, project, commit) + base = ['https://gitlab.com/', namespace, '/', project].join('') + [base, [base, '/-/tree/', commit].join('')] + end + + def github_com_tree_links(namespace, project, commit) + base = ['https://github.com/', namespace, '/', project].join('') [base, [base, '/tree/', commit].join('')] end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 51cbe93513d..05d698a6d99 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -2,6 +2,7 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { + 'cherry_pick' => 'link', 'commit' => 'commit', 'description' => 'pencil-square', 'merge' => 'git-merge', diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 58edb327be0..0f156003a01 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -12,6 +12,7 @@ module TabHelper # :action - One or more action names to check (optional). # :path - A shorthand path, such as 'dashboard#index', to check (optional). # :html_options - Extra options to be passed to the list element (optional). + # :unless - Callable object to skip rendering the 'active' class on `li` element (optional). # block - An optional block that will become the contents of the returned # `li` element. # @@ -56,6 +57,14 @@ module TabHelper # nav_link(path: 'admin/appearances#show') { "Hello"} # # => '<li class="active">Hello</li>' # + # # Shorthand path + unless + # # Add `active` class when TreeController is requested, except the `index` action. + # nav_link(controller: 'tree', unless: -> { action_name?('index') }) { "Hello" } + # # => '<li class="active">Hello</li>' + # + # # When `TreeController#index` is requested + # # => '<li>Hello</li>' + # # Returns a list item element String def nav_link(options = {}, &block) klass = active_nav_link?(options) ? 'active' : '' @@ -73,6 +82,8 @@ module TabHelper end def active_nav_link?(options) + return false if options[:unless]&.call + if path = options.delete(:path) unless path.respond_to?(:each) path = [path] diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index af1919eeb40..0b50b8b1130 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -38,13 +38,13 @@ module TreeHelper # many paths, as with a repository tree that has thousands of items. def fast_project_blob_path(project, blob_path) ActionDispatch::Journey::Router::Utils.escape_path( - File.join(relative_url_root, project.path_with_namespace, 'blob', blob_path) + File.join(relative_url_root, project.path_with_namespace, '-', 'blob', blob_path) ) end def fast_project_tree_path(project, tree_path) ActionDispatch::Journey::Router::Utils.escape_path( - File.join(relative_url_root, project.path_with_namespace, 'tree', tree_path) + File.join(relative_url_root, project.path_with_namespace, '-', 'tree', tree_path) ) end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index b3eee25674b..ab691916706 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -22,6 +22,9 @@ module UserCalloutsHelper def render_dashboard_gold_trial(user) end + def render_account_recovery_regular_check + end + def show_suggest_popover? !user_dismissed?(SUGGEST_POPOVER_DISMISSED) end @@ -32,8 +35,10 @@ module UserCalloutsHelper private - def user_dismissed?(feature_name) - current_user&.callouts&.find_by(feature_name: UserCallout.feature_names[feature_name]) + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) + return false unless current_user + + current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) end end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index e0aa66e6de3..0f2f63b43f5 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AbuseReportMailer < BaseMailer +class AbuseReportMailer < ApplicationMailer layout 'empty_mailer' helper EmailsHelper diff --git a/app/mailers/base_mailer.rb b/app/mailers/application_mailer.rb index 5fd209c4761..e0c95370072 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class BaseMailer < ActionMailer::Base +class ApplicationMailer < ActionMailer::Base around_action :render_with_default_locale helper ApplicationHelper diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb index d743533b1bc..25721658285 100644 --- a/app/mailers/email_rejection_mailer.rb +++ b/app/mailers/email_rejection_mailer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EmailRejectionMailer < BaseMailer +class EmailRejectionMailer < ApplicationMailer layout 'empty_mailer' helper EmailsHelper diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index de70d0073b3..6dd4ccb510a 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -26,19 +26,17 @@ module Emails mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id, reason)) end - def note_project_snippet_email(recipient_id, note_id, reason = nil) + def note_snippet_email(recipient_id, note_id, reason = nil) setup_note_mail(note_id, recipient_id) - @snippet = @note.noteable - @target_url = project_snippet_url(*note_target_url_options) - mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason)) - end - def note_personal_snippet_email(recipient_id, note_id, reason = nil) - setup_note_mail(note_id, recipient_id) + case @snippet + when ProjectSnippet + @target_url = project_snippet_url(*note_target_url_options) + when Snippet + @target_url = gitlab_snippet_url(@note.noteable) + end - @snippet = @note.noteable - @target_url = gitlab_snippet_url(@note.noteable) mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason)) end diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 95bb52d8f97..773b9fead3a 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -15,7 +15,13 @@ module Emails def pipeline_mail(pipeline, recipients, status) @project = pipeline.project @pipeline = pipeline - @merge_request = pipeline.all_merge_requests.first + + @merge_request = if pipeline.merge_request? + pipeline.merge_request + else + pipeline.merge_requests_as_head_pipeline.first + end + add_headers # We use bcc here because we don't want to generate these emails for a diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 92939136de2..49eacc44519 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Notify < BaseMailer +class Notify < ApplicationMailer include ActionDispatch::Routing::PolymorphicRoutes include GitlabRoutingHelper include EmailsHelper diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb index aa56ba1828b..b8f990f26c8 100644 --- a/app/mailers/repository_check_mailer.rb +++ b/app/mailers/repository_check_mailer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RepositoryCheckMailer < BaseMailer +class RepositoryCheckMailer < ApplicationMailer # rubocop: disable CodeReuse/ActiveRecord layout 'empty_mailer' diff --git a/app/models/ability.rb b/app/models/ability.rb index 1466407d0d1..671a92632d5 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -24,7 +24,7 @@ class Ability # read the given snippet. def users_that_can_read_personal_snippet(users, snippet) DeclarativePolicy.subject_scope do - users.select { |u| allowed?(u, :read_personal_snippet, snippet) } + users.select { |u| allowed?(u, :read_snippet, snippet) } end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 10d15e84b8d..ddd43311d9b 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -10,7 +10,9 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token - belongs_to :instance_administration_project, class_name: "Project" + belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id' + alias_attribute :self_monitoring_project_id, :instance_administration_project_id + belongs_to :instance_administrators_group, class_name: "Group" # Include here so it can override methods from @@ -142,7 +144,7 @@ class ApplicationSetting < ApplicationRecord if: :auto_devops_enabled? validates :enabled_git_access_protocol, - inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true } + inclusion: { in: %w(ssh http), allow_blank: true } validates :domain_blacklist, presence: { message: 'Domain blacklist cannot be empty if Blacklist is enabled.' }, diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 06a607b75a4..03eb7462ece 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -13,6 +13,8 @@ class AuditEvent < ApplicationRecord scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } + scope :order_by_id_desc, -> { order(id: :desc) } + scope :order_by_id_asc, -> { order(id: :asc) } after_initialize :initialize_details diff --git a/app/models/blob.rb b/app/models/blob.rb index 42ee00bc196..d8282c918b7 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -65,7 +65,10 @@ class Blob < SimpleDelegator BlobViewer::YarnLock ].freeze - attr_reader :project + attr_reader :container + + delegate :repository, to: :container, allow_nil: true + delegate :project, to: :repository, allow_nil: true # Wrap a Gitlab::Git::Blob object, or return nil when given nil # @@ -77,22 +80,22 @@ class Blob < SimpleDelegator # # blob = Blob.decorate(nil) # puts "truthy" if blob # No output - def self.decorate(blob, project = nil) + def self.decorate(blob, container = nil) return if blob.nil? - new(blob, project) + new(blob, container) end - def self.lazy(project, commit_id, path) - BatchLoader.for([commit_id, path]).batch(key: project.repository) do |items, loader, args| - args[:key].blobs_at(items).each do |blob| + def self.lazy(container, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) + BatchLoader.for([commit_id, path]).batch(key: container.repository) do |items, loader, args| + args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob| loader.call([blob.commit_id, blob.path], blob) if blob end end end - def initialize(blob, project = nil) - @project = project + def initialize(blob, container = nil) + @container = container super(blob) end @@ -116,7 +119,7 @@ class Blob < SimpleDelegator def load_all_data! # Endpoint needed: https://gitlab.com/gitlab-org/gitaly/issues/756 Gitlab::GitalyClient.allow_n_plus_1_calls do - super(project.repository) if project + super(repository) if container end end diff --git a/app/models/board.rb b/app/models/board.rb index 38bbb550044..a57d101b30a 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -11,7 +11,10 @@ class Board < ApplicationRecord validates :group, presence: true, unless: :project scope :with_associations, -> { preload(:destroyable_lists) } - scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } + + # Sort by case-insensitive name, then ascending ids. This ensures that we will always + # get the same list/first board no matter how many other boards are named the same + scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) } scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) } def project_needed? diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index e6d41dd2779..26997d17816 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -4,19 +4,91 @@ module Ci class Bridge < Ci::Processable include Ci::Contextable include Ci::PipelineDelegator + include Ci::Metadatable include Importable include AfterCommitQueue include HasRef - include Gitlab::Utils::StrongMemoize + + InvalidBridgeTypeError = Class.new(StandardError) belongs_to :project belongs_to :trigger_request + has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline", + foreign_key: :source_job_id + validates :ref, presence: true + # rubocop:disable Cop/ActiveRecordSerialize + serialize :options + serialize :yaml_variables, ::Gitlab::Serializer::Ci::Variables + # rubocop:enable Cop/ActiveRecordSerialize + + state_machine :status do + after_transition created: :pending do |bridge| + next unless bridge.downstream_project + + bridge.run_after_commit do + bridge.schedule_downstream_pipeline! + end + end + + event :manual do + transition all => :manual + end + + event :scheduled do + transition all => :scheduled + end + end + def self.retry(bridge, current_user) raise NotImplementedError end + def schedule_downstream_pipeline! + raise InvalidBridgeTypeError unless downstream_project + + ::Ci::CreateCrossProjectPipelineWorker.perform_async(self.id) + end + + def inherit_status_from_downstream!(pipeline) + case pipeline.status + when 'success' + self.success! + when 'failed', 'canceled', 'skipped' + self.drop! + else + false + end + end + + def downstream_pipeline_params + return child_params if triggers_child_pipeline? + return cross_project_params if downstream_project.present? + + {} + end + + def downstream_project + strong_memoize(:downstream_project) do + if downstream_project_path + ::Project.find_by_full_path(downstream_project_path) + elsif triggers_child_pipeline? + project + end + end + end + + def downstream_project_path + strong_memoize(:downstream_project_path) do + options&.dig(:trigger, :project) + end + end + + def triggers_child_pipeline? + yaml_for_downstream.present? + end + def tags [:bridge] end @@ -55,7 +127,69 @@ module Ci end def yaml_for_downstream - nil + strong_memoize(:yaml_for_downstream) do + includes = options&.dig(:trigger, :include) + YAML.dump('include' => includes) if includes + end + end + + def target_ref + branch = options&.dig(:trigger, :branch) + return unless branch + + scoped_variables.to_runner_variables.yield_self do |all_variables| + ::ExpandVariables.expand(branch, all_variables) + end + end + + def dependent? + strong_memoize(:dependent) do + options&.dig(:trigger, :strategy) == 'depend' + end + end + + def downstream_variables + variables = scoped_variables.concat(pipeline.persisted_variables) + + variables.to_runner_variables.yield_self do |all_variables| + yaml_variables.to_a.map do |hash| + { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) } + end + end + end + + private + + def cross_project_params + { + project: downstream_project, + source: :pipeline, + target_revision: { + ref: target_ref || downstream_project.default_branch + }, + execute_params: { ignore_skip_ci: true } + } + end + + def child_params + parent_pipeline = pipeline + + { + project: project, + source: :parent_pipeline, + target_revision: { + ref: parent_pipeline.ref, + checkout_sha: parent_pipeline.sha, + before: parent_pipeline.before_sha, + source_sha: parent_pipeline.source_sha, + target_sha: parent_pipeline.target_sha + }, + execute_params: { + ignore_skip_ci: true, + bridge: self, + merge_request: parent_pipeline.merge_request + } + } end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 369a793f3d5..e95e2c538c5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -10,7 +10,6 @@ module Ci include ObjectStorage::BackgroundMove include Presentable include Importable - include Gitlab::Utils::StrongMemoize include HasRef include IgnorableColumns @@ -23,6 +22,7 @@ module Ci belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -114,6 +114,7 @@ module Ci end scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } + scope :eager_load_job_artifacts_archive, -> { includes(:job_artifacts_archive) } scope :eager_load_everything, -> do includes( @@ -172,6 +173,9 @@ module Ci scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } scope :order_id_desc, -> { order('ci_builds.id DESC') } + PROJECT_ROUTE_AND_NAMESPACE_ROUTE = { project: [:project_feature, :route, { namespace: :route }] }.freeze + scope :preload_project_and_pipeline_project, -> { preload(PROJECT_ROUTE_AND_NAMESPACE_ROUTE, pipeline: PROJECT_ROUTE_AND_NAMESPACE_ROUTE) } + acts_as_taggable add_authentication_token_field :token, encrypted: :optional @@ -760,8 +764,8 @@ module Ci end end - def has_expiring_artifacts? - artifacts_expire_at.present? && artifacts_expire_at > Time.now + def has_expiring_archive_artifacts? + has_expiring_artifacts? && job_artifacts_archive.present? end def keep_artifacts! @@ -815,7 +819,7 @@ module Ci depended_jobs = depends_on_builds # find all jobs that are needed - if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && needs.exists? + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && scheduling_type_dag? depended_jobs = depended_jobs.where(name: needs.artifacts.select(:name)) end @@ -976,6 +980,10 @@ module Ci value.with_indifferent_access end end + + def has_expiring_artifacts? + artifacts_expire_at.present? && artifacts_expire_at > Time.now + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 9eca324f0fc..564853fc8a1 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -27,7 +27,8 @@ module Ci license_management: 'gl-license-management-report.json', license_scanning: 'gl-license-scanning-report.json', performance: 'performance.json', - metrics: 'metrics.txt' + metrics: 'metrics.txt', + lsif: 'lsif.json' }.freeze INTERNAL_TYPES = { @@ -52,7 +53,8 @@ module Ci dast: :raw, license_management: :raw, license_scanning: :raw, - performance: :raw + performance: :raw, + lsif: :raw }.freeze TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze @@ -72,6 +74,7 @@ module Ci scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } + scope :for_sha, ->(sha) { joins(job: :pipeline).where(ci_pipelines: { sha: sha }) } scope :with_file_types, -> (file_types) do types = self.file_types.select { |file_type| file_types.include?(file_type) }.values @@ -114,7 +117,8 @@ module Ci performance: 11, ## EE-specific metrics: 12, ## EE-specific metrics_referee: 13, ## runner referees - network_referee: 14 ## runner referees + network_referee: 14, ## runner referees + lsif: 15 # LSIF data for code navigation } enum file_format: { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 7e3ba98d86c..3209e077a08 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -16,6 +16,8 @@ module Ci include FromUnion include UpdatedAtFilterable + BridgeStatusError = Class.new(StandardError) + sha_attribute :source_sha sha_attribute :target_sha @@ -64,6 +66,7 @@ module Ci has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline has_one :source_job, through: :source_pipeline, source: :source_job + has_one :source_bridge, through: :source_pipeline, source: :source_bridge has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline @@ -74,9 +77,7 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } - validates :merge_request, presence: { if: :merge_request_event? } - validates :merge_request, absence: { unless: :merge_request_event? } - validates :tag, inclusion: { in: [false], if: :merge_request_event? } + validates :tag, inclusion: { in: [false], if: :merge_request? } validates :external_pull_request, presence: { if: :external_pull_request_event? } validates :external_pull_request, absence: { unless: :external_pull_request_event? } @@ -184,7 +185,7 @@ module Ci pipeline.run_after_commit do PipelineHooksWorker.perform_async(pipeline.id) - ExpirePipelineCacheWorker.perform_async(pipeline.id) + ExpirePipelineCacheWorker.perform_async(pipeline.id) if pipeline.cacheable? end end @@ -204,6 +205,22 @@ module Ci end end + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + next unless pipeline.bridge_triggered? + next unless pipeline.bridge_waiting? + + pipeline.run_after_commit do + ::Ci::PipelineBridgeStatusWorker.perform_async(pipeline.id) + end + end + + after_transition created: :pending do |pipeline| + next unless pipeline.bridge_triggered? + next if pipeline.bridge_waiting? + + pipeline.update_bridge_status! + end + after_transition any => [:success, :failed] do |pipeline| pipeline.run_after_commit do PipelineNotificationWorker.perform_async(pipeline.id) @@ -578,7 +595,7 @@ module Ci # Manually set the notes for a Ci::Pipeline # There is no ActiveRecord relation between Ci::Pipeline and notes # as they are related to a commit sha. This method helps importing - # them using the +Gitlab::ImportExport::RelationFactory+ class. + # them using the +Gitlab::ImportExport::ProjectRelationFactory+ class. def notes=(notes) notes.each do |note| note[:id] = nil @@ -643,7 +660,7 @@ module Ci variables.concat(predefined_commit_variables) - if merge_request_event? && merge_request + if merge_request? variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) @@ -701,7 +718,7 @@ module Ci # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= - if merge_request_event? + if merge_request? MergeRequest.where(id: merge_request_id) else MergeRequest.where(source_project_id: project_id, source_branch: ref) @@ -722,6 +739,21 @@ module Ci end end + def update_bridge_status! + raise ArgumentError unless bridge_triggered? + raise BridgeStatusError unless source_bridge.active? + + source_bridge.success! + end + + def bridge_triggered? + source_bridge.present? + end + + def bridge_waiting? + source_bridge&.dependent? + end + def child? parent_pipeline.present? end @@ -755,6 +787,12 @@ module Ci end end + def test_reports_count + Rails.cache.fetch(['project', project.id, 'pipeline', id, 'test_reports_count'], force: false) do + test_reports.total_count + end + end + def has_exposed_artifacts? complete? && builds.latest.with_exposed_artifacts.exists? end @@ -772,7 +810,7 @@ module Ci # * nil: Modified path can not be evaluated def modified_paths strong_memoize(:modified_paths) do - if merge_request_event? + if merge_request? merge_request.modified_paths elsif branch_updated? push_details.modified_paths @@ -796,12 +834,12 @@ module Ci ref == project.default_branch end - def triggered_by_merge_request? - merge_request_event? && merge_request_id.present? + def merge_request? + merge_request_id.present? end def detached_merge_request_pipeline? - triggered_by_merge_request? && target_sha.nil? + merge_request? && target_sha.nil? end def legacy_detached_merge_request_pipeline? @@ -809,7 +847,7 @@ module Ci end def merge_request_pipeline? - triggered_by_merge_request? && target_sha.present? + merge_request? && target_sha.present? end def merge_request_ref? @@ -825,7 +863,7 @@ module Ci end def source_ref - if triggered_by_merge_request? + if merge_request? merge_request.source_branch else ref @@ -845,7 +883,7 @@ module Ci end def merge_request_event_type - return unless merge_request_event? + return unless merge_request? strong_memoize(:merge_request_event_type) do if merge_request_pipeline? @@ -864,6 +902,10 @@ module Ci statuses.latest.success.where(name: names).pluck(:id) end + def cacheable? + Ci::PipelineEnums.ci_config_sources.key?(config_source.to_sym) + end + private def pipeline_data @@ -878,7 +920,7 @@ module Ci def git_ref strong_memoize(:git_ref) do - if merge_request_event? + if merge_request? ## # In the future, we're going to change this ref to # merge request's merged reference, such as "refs/merge-requests/:iid/merge". diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index fde169d2f03..7e203cb67c4 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -46,13 +46,18 @@ module Ci } end - def self.ci_config_sources_values - config_sources.values_at( + def self.ci_config_sources + config_sources.slice( :unknown_source, :repository_source, :auto_devops_source, :remote_source, - :external_project_source) + :external_project_source + ) + end + + def self.ci_config_sources_values + ci_config_sources.values end end end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 9a1445e624c..f5785000062 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -23,7 +23,7 @@ module Ci scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } - scope :preloaded, -> { preload(:owner, :project) } + scope :preloaded, -> { preload(:owner, project: [:route]) } accepts_nested_attributes_for :variables, allow_destroy: true diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 6c4b271cd2c..6c080582cae 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -2,12 +2,28 @@ module Ci class Processable < ::CommitStatus + include Gitlab::Utils::StrongMemoize + has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build accepts_nested_attributes_for :needs + enum scheduling_type: { stage: 0, dag: 1 }, _prefix: true + scope :preload_needs, -> { preload(:needs) } + scope :with_needs, -> (names = nil) do + needs = Ci::BuildNeed.scoped_build.select(1) + needs = needs.where(name: names) if names + where('EXISTS (?)', needs).preload(:needs) + end + + scope :without_needs, -> (names = nil) do + needs = Ci::BuildNeed.scoped_build.select(1) + needs = needs.where(name: names) if names + where('NOT EXISTS (?)', needs) + end + def self.select_with_aggregated_needs(project) return all unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) @@ -22,7 +38,20 @@ module Ci ) end + # Old processables may have scheduling_type as nil, + # so we need to ensure the data exists before using it. + def self.populate_scheduling_type! + needs = Ci::BuildNeed.scoped_build.select(1) + where(scheduling_type: nil).update_all( + "scheduling_type = CASE WHEN (EXISTS (#{needs.to_sql})) + THEN #{scheduling_types[:dag]} + ELSE #{scheduling_types[:stage]} + END" + ) + end + validates :type, presence: true + validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type? def aggregated_needs_names read_attribute(:aggregated_needs_names) @@ -47,5 +76,24 @@ module Ci def scoped_variables_hash raise NotImplementedError end + + # Overriding scheduling_type enum's method for nil `scheduling_type`s + def scheduling_type_dag? + super || find_legacy_scheduling_type == :dag + end + + # scheduling_type column of previous builds/bridges have not been populated, + # so we calculate this value on runtime when we need it. + def find_legacy_scheduling_type + strong_memoize(:find_legacy_scheduling_type) do + needs.exists? ? :dag : :stage + end + end + + private + + def validate_scheduling_type? + !importing? && Feature.enabled?(:validate_scheduling_type_of_processables, project) + end end end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index d71e3b55b9a..f19aac213be 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -10,6 +10,7 @@ module Ci belongs_to :source_project, class_name: "Project", foreign_key: :source_project_id belongs_to :source_job, class_name: "CommitStatus", foreign_key: :source_job_id + belongs_to :source_bridge, class_name: "Ci::Bridge", foreign_key: :source_job_id belongs_to :source_pipeline, class_name: "Ci::Pipeline", foreign_key: :source_pipeline_id validates :project, presence: true @@ -23,5 +24,3 @@ module Ci end end end - -::Ci::Sources::Pipeline.prepend_if_ee('::EE::Ci::Sources::Pipeline') diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index e86a4597ed8..ce42bc65579 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -16,7 +16,7 @@ module Clusters include ::Gitlab::Utils::StrongMemoize include IgnorableColumns - ignore_column :kibana_hostname, remove_with: '12.8', remove_after: '2020-01-22' + ignore_column :kibana_hostname, remove_with: '12.9', remove_after: '2020-02-22' default_value_for :version, VERSION @@ -30,7 +30,8 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files + files: files, + postinstall: post_install_script ) end @@ -43,6 +44,10 @@ module Clusters ) end + def files + super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh")) + end + def elasticsearch_client strong_memoize(:elasticsearch_client) do next unless kube_client @@ -69,10 +74,16 @@ module Clusters private + def post_install_script + [ + "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-client:9200" + ] + end + def post_delete_script [ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack") - ].compact + ] end def kube_client diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 63f216c7af5..bdd7ad90fba 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -3,7 +3,8 @@ module Clusters module Applications class Ingress < ApplicationRecord - VERSION = '1.22.1' + VERSION = '1.29.3' + MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log' self.table_name = 'clusters_applications_ingress' @@ -85,7 +86,7 @@ module Clusters }, "extraContainers" => [ { - "name" => "modsecurity-log", + "name" => MODSECURITY_LOG_CONTAINER_NAME, "image" => "busybox", "args" => [ "/bin/sh", diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 387503bee54..eebcbcba2d3 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -11,7 +11,7 @@ module Clusters self.table_name = 'clusters_applications_knative' - has_one :serverless_domain_cluster, class_name: 'Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :knative + has_one :serverless_domain_cluster, class_name: '::Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :knative include ::Clusters::Concerns::ApplicationCore include ::Clusters::Concerns::ApplicationStatus @@ -74,7 +74,7 @@ module Clusters end def ingress_service - cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system') + cluster.kubeclient.get_service('istio-ingressgateway', Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE) end def uninstall_command diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index d24a298b0a6..adce55cb61b 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -99,6 +99,8 @@ module Clusters def configured? kube_client.present? && available? + rescue Gitlab::UrlBlocker::BlockedUrlError + false end private diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index a908ca28188..6a9cd77d356 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.12.0' + VERSION = '0.13.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index d2eee78f3df..7e76d324bdc 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -31,6 +31,7 @@ module Clusters has_many :cluster_projects, class_name: 'Clusters::Project' has_many :projects, through: :cluster_projects, class_name: '::Project' has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project' + has_many :deployment_clusters has_many :cluster_groups, class_name: 'Clusters::Group' has_many :groups, through: :cluster_groups, class_name: '::Group' @@ -289,6 +290,12 @@ module Clusters end end + def serverless_domain + strong_memoize(:serverless_domain) do + self.application_knative&.serverless_domain_cluster + end + end + private def unique_management_project_environment_scope diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index ae720065387..444368d0ef3 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -92,7 +92,10 @@ module Clusters def calculate_reactive_cache_for(environment) return unless enabled? - { pods: read_pods(environment.deployment_namespace) } + pods = read_pods(environment.deployment_namespace) + + # extract_relevant_pod_data avoids uploading all the pod info into ReactiveCaching + { pods: extract_relevant_pod_data(pods) } end def terminals(environment, data) @@ -203,6 +206,21 @@ module Clusters def nullify_blank_namespace self.namespace = nil if namespace.blank? end + + def extract_relevant_pod_data(pods) + pods.map do |pod| + { + 'metadata' => pod.fetch('metadata', {}) + .slice('name', 'generateName', 'labels', 'annotations', 'creationTimestamp'), + 'status' => pod.fetch('status', {}).slice('phase'), + 'spec' => { + 'containers' => pod.fetch('spec', {}) + .fetch('containers', []) + .map { |c| c.slice('name') } + } + } + end + end end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 460725b2016..d8a3bbfeeb2 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -21,11 +21,14 @@ class Commit participant :committer participant :notes_with_associations - attr_accessor :project, :author + attr_accessor :author attr_accessor :redacted_description_html attr_accessor :redacted_title_html attr_accessor :redacted_full_title_html - attr_reader :gpg_commit + attr_reader :container + + delegate :repository, to: :container + delegate :project, to: :repository, allow_nil: true DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] @@ -44,12 +47,12 @@ class Commit cache_markdown_field :description, pipeline: :commit_description class << self - def decorate(commits, project) + def decorate(commits, container) commits.map do |commit| if commit.is_a?(Commit) commit else - self.new(commit, project) + self.new(commit, container) end end end @@ -85,24 +88,24 @@ class Commit } end - def from_hash(hash, project) - raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash) - new(raw_commit, project) + def from_hash(hash, container) + raw_commit = Gitlab::Git::Commit.new(container.repository.raw, hash) + new(raw_commit, container) end def valid_hash?(key) !!(EXACT_COMMIT_SHA_PATTERN =~ key) end - def lazy(project, oid) - BatchLoader.for({ project: project, oid: oid }).batch(replace_methods: false) do |items, loader| - items_by_project = items.group_by { |i| i[:project] } + def lazy(container, oid) + BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader| + items_by_container = items.group_by { |i| i[:container] } - items_by_project.each do |project, commit_ids| + items_by_container.each do |container, commit_ids| oids = commit_ids.map { |i| i[:oid] } - project.repository.commits_by(oids: oids).each do |commit| - loader.call({ project: commit.project, oid: commit.id }, commit) if commit + container.repository.commits_by(oids: oids).each do |commit| + loader.call({ container: commit.container, oid: commit.id }, commit) if commit end end end @@ -115,12 +118,11 @@ class Commit attr_accessor :raw - def initialize(raw_commit, project) + def initialize(raw_commit, container) raise "Nil as raw commit passed" unless raw_commit @raw = raw_commit - @project = project - @gpg_commit = Gitlab::Gpg::Commit.new(self) if project + @container = container end delegate \ @@ -141,7 +143,7 @@ class Commit end def project_id - project.id + project&.id end def ==(other) @@ -242,6 +244,8 @@ class Commit # Discover issues should be closed when this commit is pushed to a project's # default branch. def closes_issues(current_user = self.committer) + return unless repository.repo_type.project? + Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message) end @@ -269,17 +273,17 @@ class Commit end def parents - @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) } + @parents ||= parent_ids.map { |oid| Commit.lazy(container, oid) } end def parent strong_memoize(:parent) do - project.commit_by(oid: self.parent_id) if self.parent_id + container.commit_by(oid: self.parent_id) if self.parent_id end end def notes - project.notes.for_commit_id(self.id) + container.notes.for_commit_id(self.id) end def user_mentions @@ -295,7 +299,11 @@ class Commit end def merge_requests - @merge_requests ||= project.merge_requests.by_commit_sha(sha) + strong_memoize(:merge_requests) do + next MergeRequest.none unless repository.repo_type.project? && project + + project.merge_requests.by_commit_sha(sha) + end end def method_missing(method, *args, &block) @@ -317,20 +325,41 @@ class Commit ) end - def signature - return @signature if defined?(@signature) + def has_signature? + signature_type && signature_type != :NONE + end - @signature = gpg_commit.signature + def raw_signature_type + strong_memoize(:raw_signature_type) do + next unless @raw.instance_of?(Gitlab::Git::Commit) + + @raw.raw_commit.signature_type if defined? @raw.raw_commit.signature_type + end end - delegate :has_signature?, to: :gpg_commit + def signature_type + @signature_type ||= raw_signature_type || :NONE + end + + def signature + strong_memoize(:signature) do + case signature_type + when :PGP + Gitlab::Gpg::Commit.new(self).signature + when :X509 + Gitlab::X509::Commit.new(self).signature + else + nil + end + end + end def revert_branch_name "revert-#{short_id}" end def cherry_pick_branch_name - project.repository.next_branch("cherry-pick-#{short_id}", mild: true) + repository.next_branch("cherry-pick-#{short_id}", mild: true) end def cherry_pick_description(user) @@ -418,7 +447,7 @@ class Commit return unless entry if entry[:type] == :blob - blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) + blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), container) blob.image? || blob.video? || blob.audio? ? :raw : :blob else entry[:type] @@ -484,10 +513,10 @@ class Commit end def commit_reference(from, referable_commit_id, full: false) - reference = project.to_reference(from, full: full) + base = container.to_reference_base(from, full: full) - if reference.present? - "#{reference}#{self.class.reference_prefix}#{referable_commit_id}" + if base.present? + "#{base}#{self.class.reference_prefix}#{referable_commit_id}" else referable_commit_id end @@ -510,6 +539,6 @@ class Commit end def merged_merge_request_no_cache(user) - MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit? + MergeRequestsFinder.new(user, project_id: project_id).find_by(merge_commit_sha: id) if merge_commit? end end diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index d4c29aa295b..456d32bf403 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -1,17 +1,20 @@ # frozen_string_literal: true -# A collection of Commit instances for a specific project and Git reference. +# A collection of Commit instances for a specific container and Git reference. class CommitCollection include Enumerable include Gitlab::Utils::StrongMemoize - attr_reader :project, :ref, :commits + attr_reader :container, :ref, :commits - # project - The project the commits belong to. + delegate :repository, to: :container, allow_nil: true + delegate :project, to: :repository, allow_nil: true + + # container - The object the commits belong to. # commits - The Commit instances to store. # ref - The name of the ref (e.g. "master"). - def initialize(project, commits, ref = nil) - @project = project + def initialize(container, commits, ref = nil) + @container = container @commits = commits @ref = ref end @@ -39,6 +42,8 @@ class CommitCollection # Setting the pipeline for each commit ahead of time removes the need for running # a query for every commit we're displaying. def with_latest_pipeline(ref = nil) + return self unless project + pipelines = project.ci_pipelines.latest_pipeline_per_commit(map(&:id), ref) each do |commit| @@ -59,16 +64,16 @@ class CommitCollection # Batch load any commits that are not backed by full gitaly data, and # replace them in the collection. def enrich! - # A project is needed in order to fetch data from gitaly. Projects + # A container is needed in order to fetch data from gitaly. Containers # can be absent from commits in certain rare situations (like when # viewing a MR of a deleted fork). In these cases, assume that the # enriched data is not needed. - return self if project.blank? || fully_enriched? + return self if container.blank? || fully_enriched? # Batch load full Commits from the repository # and map to a Hash of id => Commit replacements = Hash[unenriched.map do |c| - [c.id, Commit.lazy(project, c.id)] + [c.id, Commit.lazy(container, c.id)] end.compact] # Replace the commits, keeping the same order diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 08ca86bc902..08f1eb3731e 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -92,7 +92,7 @@ class CommitRange alias_method :id, :to_s def to_reference(from = nil, full: false) - project_reference = project.to_reference(from, full: full) + project_reference = project.to_reference_base(from, full: full) if project_reference.present? project_reference + self.class.reference_prefix + self.id @@ -102,7 +102,7 @@ class CommitRange end def reference_link_text(from = nil) - project_reference = project.to_reference(from) + project_reference = project.to_reference_base(from) reference = ref_from + notation + ref_to if project_reference.present? diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index f9101609f89..35b727720ba 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -62,18 +62,6 @@ class CommitStatus < ApplicationRecord preload(project: :namespace) end - scope :with_needs, -> (names = nil) do - needs = Ci::BuildNeed.scoped_build.select(1) - needs = needs.where(name: names) if names - where('EXISTS (?)', needs).preload(:needs) - end - - scope :without_needs, -> (names = nil) do - needs = Ci::BuildNeed.scoped_build.select(1) - needs = needs.where(name: names) if names - where('NOT EXISTS (?)', needs) - end - scope :match_id_and_lock_version, -> (slice) do # it expects that items are an array of attributes to match # each hash needs to have `id` and `lock_version` @@ -200,6 +188,10 @@ class CommitStatus < ApplicationRecord update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1') end + def self.locking_enabled? + false + end + def locking_enabled? will_save_change_to_status? end diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb index 2ca6d15e642..caebff91022 100644 --- a/app/models/commit_status_enums.rb +++ b/app/models/commit_status_enums.rb @@ -17,7 +17,13 @@ module CommitStatusEnums archived_failure: 9, unmet_prerequisites: 10, scheduler_failure: 11, - data_integrity_failure: 12 + data_integrity_failure: 12, + forward_deployment_failure: 13, + insufficient_bridge_permissions: 1_001, + downstream_bridge_project_not_found: 1_002, + invalid_bridge_trigger: 1_003, + bridge_pipeline_is_child_pipeline: 1_006, + downstream_pipeline_creation_failed: 1_007 } end end diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index dde73b567db..39e8408f794 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -15,8 +15,8 @@ module Analytics validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? validates :start_event_identifier, presence: true validates :end_event_identifier, presence: true - validates :start_event_label, presence: true, if: :start_event_label_based? - validates :end_event_label, presence: true, if: :end_event_label_based? + validates :start_event_label_id, presence: true, if: :start_event_label_based? + validates :end_event_label_id, presence: true, if: :end_event_label_based? validate :validate_stage_event_pairs validate :validate_labels @@ -109,8 +109,8 @@ module Analytics end def validate_labels - validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed? - validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed? + validate_label_within_group(:start_event_label_id, start_event_label_id) if start_event_label_id_changed? + validate_label_within_group(:end_event_label_id, end_event_label_id) if end_event_label_id_changed? end def validate_label_within_group(association_name, label_id) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 3e9b084e784..4a632e8cd0c 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,7 +27,7 @@ module AtomicInternalId extend ActiveSupport::Concern class_methods do - def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true, backfill: false) # rubocop:disable Naming/PredicateName # We require init here to retain the ability to recalculate in the absence of a # InternalId record (we may delete records in `internal_ids` for example). raise "has_internal_id requires a init block, none given." unless init @@ -38,6 +38,8 @@ module AtomicInternalId validates column, presence: presence define_method("ensure_#{scope}_#{column}!") do + return if backfill && self.class.where(column => nil).exists? + scope_value = internal_id_read_scope(scope) value = read_attribute(column) return value unless scope_value diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb new file mode 100644 index 00000000000..6d75906b21f --- /dev/null +++ b/app/models/concerns/bulk_insert_safe.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module BulkInsertSafe + extend ActiveSupport::Concern + + # These are the callbacks we think safe when used on models that are + # written to the database in bulk + CALLBACK_NAME_WHITELIST = Set[ + :initialize, + :validate, + :validation, + :find, + :destroy + ].freeze + + MethodNotAllowedError = Class.new(StandardError) + + class_methods do + def set_callback(name, *args) + unless _bulk_insert_callback_allowed?(name, args) + raise MethodNotAllowedError.new( + "Not allowed to call `set_callback(#{name}, #{args})` when model extends `BulkInsertSafe`." \ + "Callbacks that fire per each record being inserted do not work with bulk-inserts.") + end + + super + end + + private + + def _bulk_insert_callback_allowed?(name, args) + _bulk_insert_whitelisted?(name) || _bulk_insert_saved_from_belongs_to?(name, args) + end + + # belongs_to associations will install a before_save hook during class loading + def _bulk_insert_saved_from_belongs_to?(name, args) + args.first == :before && args.second.to_s.start_with?('autosave_associated_records_for_') + end + + def _bulk_insert_whitelisted?(name) + CALLBACK_NAME_WHITELIST.include?(name) + end + end +end diff --git a/app/models/concerns/cached_commit.rb b/app/models/concerns/cached_commit.rb new file mode 100644 index 00000000000..183d5728743 --- /dev/null +++ b/app/models/concerns/cached_commit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module CachedCommit + extend ActiveSupport::Concern + + def to_hash + Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash| + hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend + end + end + + # We don't save these, because they would need a table or a serialised + # field. They aren't used anywhere, so just pretend the commit has no parents. + def parent_ids + [] + end +end diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb index 9f95dc38422..68ad0fcee31 100644 --- a/app/models/concerns/ci/pipeline_delegator.rb +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -11,7 +11,7 @@ module Ci extend ActiveSupport::Concern included do - delegate :merge_request_event?, + delegate :merge_request?, :merge_request_ref?, :legacy_detached_merge_request_pipeline?, :merge_train_pipeline?, to: :pipeline diff --git a/app/models/concerns/delete_with_limit.rb b/app/models/concerns/delete_with_limit.rb new file mode 100644 index 00000000000..1ea18b6149b --- /dev/null +++ b/app/models/concerns/delete_with_limit.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DeleteWithLimit + extend ActiveSupport::Concern + + class_methods do + def delete_with_limit(maximum) + limit(maximum).delete_all + end + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index e4e5928f5cf..8542c48f366 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -40,7 +40,7 @@ module DiscussionOnDiff # Returns an array of at most 16 highlighted lines above a diff note def truncated_diff_lines(highlight: true, diff_limit: nil) return [] unless on_text? - return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote) + return [] if diff_line.nil? diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min lines = highlight ? highlighted_diff_lines : diff_lines diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb index fa0cf5ddfd2..22e5955984d 100644 --- a/app/models/concerns/has_ref.rb +++ b/app/models/concerns/has_ref.rb @@ -7,7 +7,7 @@ module HasRef extend ActiveSupport::Concern def branch? - !tag? && !merge_request_event? + !tag? && !merge_request? end def git_ref diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb new file mode 100644 index 00000000000..d04a6408a21 --- /dev/null +++ b/app/models/concerns/has_repository.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# This concern is created to handle repository actions. +# It should be include inside any object capable +# of directly having a repository, like project or snippet. +# +# It also includes `Referable`, therefore the method +# `to_reference` should be overriden in case the object +# needs any special behavior. +module HasRepository + extend ActiveSupport::Concern + include Gitlab::ShellAdapter + include AfterCommitQueue + include Referable + include Gitlab::Utils::StrongMemoize + + delegate :base_dir, :disk_path, to: :storage + + def valid_repo? + repository.exists? + rescue + errors.add(:path, _('Invalid repository path')) + false + end + + def repo_exists? + strong_memoize(:repo_exists) do + repository.exists? + rescue + false + end + end + + def repository_exists? + !!repository.exists? + end + + def root_ref?(branch) + repository.root_ref == branch + end + + def commit(ref = 'HEAD') + repository.commit(ref) + end + + def commit_by(oid:) + repository.commit_by(oid: oid) + end + + def commits_by(oids:) + repository.commits_by(oids: oids) + end + + def repository + raise NotImplementedError + end + + def storage + raise NotImplementedError + end + + def full_path + raise NotImplementedError + end + + def empty_repo? + repository.empty? + end + + def default_branch + @default_branch ||= repository.root_ref + end + + def reload_default_branch + @default_branch = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables + + default_branch + end + + def url_to_repo + gitlab_shell.url_to_repo(full_path) + end + + def ssh_url_to_repo + url_to_repo + end + + def http_url_to_repo + custom_root = Gitlab::CurrentSettings.custom_http_clone_url_root + + url = if custom_root.present? + Gitlab::Utils.append_path( + custom_root, + web_url(only_path: true) + ) + else + web_url + end + + "#{url}.git" + end + + def web_url(only_path: nil) + raise NotImplementedError + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index fe0fad4b9d5..78d815e5858 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -91,6 +91,7 @@ module Issuable validate :description_max_length_for_new_records_is_valid, on: :update before_validation :truncate_description_on_import! + after_save :store_mentions!, if: :any_mentionable_attributes_changed? scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } @@ -108,7 +109,9 @@ module Issuable where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)") end scope :assigned_to, ->(u) do - where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE user_id = ? AND #{to_ability_name}_id = #{to_ability_name}s.id)", u.id) + assignees_table = Arel::Table.new("#{to_ability_name}_assignees") + sql = assignees_table.project('true').where(assignees_table[:user_id].in(u)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + where("EXISTS (#{sql.to_sql})") end # rubocop:enable GitlabSecurity/SqlInjection @@ -128,6 +131,10 @@ module Issuable strip_attributes :title + def self.locking_enabled? + false + end + # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -243,7 +250,7 @@ module Issuable Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: []) + def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false) params = { target_type: name, target_column: "#{table_name}.id", @@ -259,12 +266,13 @@ module Issuable ] + extra_select_columns select(select_columns.join(', ')) - .group(arel_table[:id]) + .group(issue_grouping_columns(use_cte: with_cte)) .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def with_label(title, sort = nil) - if title.is_a?(Array) && title.size > 1 + def with_label(title, sort = nil, not_query: false) + multiple_labels = title.is_a?(Array) && title.size > 1 + if multiple_labels && !not_query joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}") else joins(:labels).where(labels: { title: title }) @@ -287,6 +295,18 @@ module Issuable grouping_columns end + # Includes all table keys in group by clause when sorting + # preventing errors in postgres when using CTE search optimisation + # + # Returns an array of arel columns + def issue_grouping_columns(use_cte: false) + if use_cte + [arel_table[:state]] + attribute_names.map { |attr| arel_table[attr.to_sym] } + else + arel_table[:id] + end + end + def to_ability_name model_name.singular end diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index fc15c6d55ed..79ff82d9f99 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -73,3 +73,5 @@ module LoadedInGroupList @member_count ||= try(:preloaded_member_count) || users.count end end + +LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods') diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index b43b91699ab..d157404f7bc 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -99,18 +99,23 @@ module Mentionable # threw the `ActiveRecord::RecordNotUnique` exception in first place. self.class.safe_ensure_unique(retries: 1) do user_mention = model_user_mention + + # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists + # as we cannot have FK on noteable_id + break if user_mention.blank? + user_mention.mentioned_users_ids = references[:mentioned_users_ids] user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] if user_mention.has_mentions? user_mention.save! - elsif user_mention.persisted? + else user_mention.destroy! end - - true end + + true end def referenced_users @@ -218,6 +223,12 @@ module Mentionable source.select { |key, val| mentionable.include?(key) } end + def any_mentionable_attributes_changed? + self.class.mentionable_attrs.any? do |attr| + saved_changes.key?(attr.first) + end + end + # Determine whether or not a cross-reference Note has already been created between this Mentionable and # the specified target. def cross_reference_exists?(target) diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 7fb3f95bf0a..7df6981a129 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -14,8 +14,6 @@ module Milestoneable validate :milestone_is_valid - after_save :write_to_new_milestone_relationship - scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :any_milestone, -> { where('milestone_id IS NOT NULL') } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -41,10 +39,6 @@ module Milestoneable def milestone_is_valid errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? end - - def write_to_new_milestone_relationship - self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id? - end end def milestone_available? diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb index 948094221e5..4dbf4dcec77 100644 --- a/app/models/concerns/mirror_authentication.rb +++ b/app/models/concerns/mirror_authentication.rb @@ -37,6 +37,8 @@ module MirrorAuthentication end define_method("#{name}=") do |value| + credentials_will_change! + self.credentials ||= {} # Removal of the password, username, etc, generally causes an update of diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index eac676f30a5..76d26500267 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -62,6 +62,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:snippets_access_level, value) end + def pages_access_level=(value) + write_feature_attribute_string(:pages_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 99da8b81398..2c171eecbd5 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -21,7 +21,7 @@ module PrometheusAdapter raise NotImplemented end - # This is a heavy-weight check if a prometheus is properly configured and accesible from GitLab. + # This is a heavy-weight check if a prometheus is properly configured and accessible from GitLab. # This actually sends a request to an external service and often it could take a long time, # Please consider using `configured?` instead if the process is running on unicorn/puma threads. def can_query? @@ -58,5 +58,12 @@ module PrometheusAdapter def build_query_args(*args) args.map { |arg| arg.respond_to?(:id) ? arg.id : arg } end + + def clear_prometheus_reactive_cache!(query_name, *args) + query_class = query_klass_for(query_name) + query_args = build_query_args(*args) + + clear_reactive_cache!(query_class.name, *query_args) + end end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 4b9896343c6..010e0018414 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -6,23 +6,24 @@ module ReactiveCaching extend ActiveSupport::Concern InvalidateReactiveCache = Class.new(StandardError) + ExceededReactiveCacheLimit = Class.new(StandardError) included do - class_attribute :reactive_cache_lease_timeout + extend ActiveModel::Naming class_attribute :reactive_cache_key - class_attribute :reactive_cache_lifetime + class_attribute :reactive_cache_lease_timeout class_attribute :reactive_cache_refresh_interval + class_attribute :reactive_cache_lifetime + class_attribute :reactive_cache_hard_limit class_attribute :reactive_cache_worker_finder # defaults self.reactive_cache_key = -> (record) { [model_name.singular, record.id] } - self.reactive_cache_lease_timeout = 2.minutes - self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes - + self.reactive_cache_hard_limit = 1.megabyte self.reactive_cache_worker_finder = ->(id, *_args) do find_by(primary_key => id) end @@ -71,6 +72,8 @@ module ReactiveCaching if within_reactive_cache_lifetime?(*args) enqueuing_update(*args) do new_value = calculate_reactive_cache(*args) + check_exceeded_reactive_cache_limit!(new_value) + old_value = Rails.cache.read(key) Rails.cache.write(key, new_value) reactive_cache_updated(*args) if new_value != old_value @@ -121,5 +124,13 @@ module ReactiveCaching ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args) end + + def check_exceeded_reactive_cache_limit!(data) + return unless Feature.enabled?(:reactive_cache_limit) + + data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit) + + raise ExceededReactiveCacheLimit.new unless data_deep_size.valid? + end end end diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 3b0606aa425..40edd3b3ead 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -23,6 +23,14 @@ module Referable '' end + # If this referable object can serve as the base for the + # reference of child objects (e.g. projects are the base of + # issues), but it is formatted differently, then you may wish + # to override this method. + def to_reference_base(from = nil, full:) + to_reference(from, full: full) + end + def reference_link_text(from = nil) to_reference(from) end diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index b645cf71443..1653ecdb305 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -237,8 +237,7 @@ module RelativePositioning relation .pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position")) - .first&. - last + .first&.last end def scoped_items diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index c0490af2453..5d78eea7fca 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -63,7 +63,7 @@ module ResolvableDiscussion return unless resolved? strong_memoize(:last_resolved_note) do - resolved_notes.sort_by(&:resolved_at).last + resolved_notes.max_by(&:resolved_at) end end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index c4af1b1fab2..4fe2a0e1827 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -8,13 +8,13 @@ module Sortable extend ActiveSupport::Concern included do - scope :with_order_id_desc, -> { order(id: :desc) } - scope :order_id_desc, -> { reorder(id: :desc) } - scope :order_id_asc, -> { reorder(id: :asc) } - scope :order_created_desc, -> { reorder(created_at: :desc) } - scope :order_created_asc, -> { reorder(created_at: :asc) } - scope :order_updated_desc, -> { reorder(updated_at: :desc) } - scope :order_updated_asc, -> { reorder(updated_at: :asc) } + scope :with_order_id_desc, -> { order(self.arel_table['id'].desc) } + scope :order_id_desc, -> { reorder(self.arel_table['id'].desc) } + scope :order_id_asc, -> { reorder(self.arel_table['id'].asc) } + scope :order_created_desc, -> { reorder(self.arel_table['created_at'].desc) } + scope :order_created_asc, -> { reorder(self.arel_table['created_at'].asc) } + scope :order_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) } + scope :order_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) } scope :order_name_asc, -> { reorder(Arel::Nodes::Ascending.new(arel_table[:name].lower)) } scope :order_name_desc, -> { reorder(Arel::Nodes::Descending.new(arel_table[:name].lower)) } end diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb new file mode 100644 index 00000000000..d2a5c736604 --- /dev/null +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module X509SerialNumberAttribute + extend ActiveSupport::Concern + + class_methods do + def x509_serial_number_attribute(name) + return if ENV['STATIC_VERIFICATION'] + + validate_binary_column_exists!(name) unless Rails.env.production? + + attribute(name, Gitlab::Database::X509SerialNumberAttribute.new) + end + + # This only gets executed in non-production environments as an additional check to ensure + # the column is the correct type. In production it should behave like any other attribute. + # See https://gitlab.com/gitlab-org/gitlab/merge_requests/5502 for more discussion + def validate_binary_column_exists!(name) + return unless database_exists? + + unless table_exists? + warn "WARNING: x509_serial_number_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" + return + end + + column = columns.find { |c| c.name == name.to_s } + + unless column + warn "WARNING: x509_serial_number_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations" + return + end + + unless column.type == :binary + raise ArgumentError.new("x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary") + end + rescue => error + Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}" + raise + end + + def database_exists? + Gitlab::Database.exists? + end + end +end diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index c929a78a7f9..ccb0a0f8acd 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -14,7 +14,7 @@ class ContainerExpirationPolicy < ApplicationRecord validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true scope :active, -> { where(enabled: true) } - scope :preloaded, -> { preload(:project) } + scope :preloaded, -> { preload(project: [:route]) } def self.keep_n_options { diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 152aa7b3218..fcbfda8fbc2 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -77,7 +77,11 @@ class ContainerRepository < ApplicationRecord end def delete_tag_by_digest(digest) - client.delete_repository_tag(self.path, digest) + client.delete_repository_tag_by_digest(self.path, digest) + end + + def delete_tag_by_name(name) + client.delete_repository_tag_by_name(self.path, name) end def self.build_from_path(path) diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 20e1d802178..31c813edb67 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -15,6 +15,11 @@ class DeployToken < ApplicationRecord has_many :project_deploy_tokens, inverse_of: :deploy_token has_many :projects, through: :project_deploy_tokens + has_many :group_deploy_tokens, inverse_of: :deploy_token + has_many :groups, through: :group_deploy_tokens + + validate :no_groups, unless: :group_type? + validate :no_projects, unless: :project_type? validate :ensure_at_least_one_scope validates :username, length: { maximum: 255 }, @@ -24,6 +29,12 @@ class DeployToken < ApplicationRecord message: "can contain only letters, digits, '_', '-', '+', and '.'" } + validates :deploy_token_type, presence: true + enum deploy_token_type: { + group_type: 1, + project_type: 2 + } + before_save :ensure_token accepts_nested_attributes_for :project_deploy_tokens @@ -51,18 +62,31 @@ class DeployToken < ApplicationRecord end def has_access_to?(requested_project) - active? && project == requested_project + return false unless active? + return false unless holder + + holder.has_access_to?(requested_project) end # This is temporal. Currently we limit DeployToken - # to a single project, later we're going to extend - # that to be for multiple projects and namespaces. + # to a single project or group, later we're going to + # extend that to be for multiple projects and namespaces. def project strong_memoize(:project) do projects.first end end + def holder + strong_memoize(:holder) do + if project_type? + project_deploy_tokens.first + elsif group_type? + group_deploy_tokens.first + end + end + end + def expires_at expires_at = read_attribute(:expires_at) expires_at != Forever.date ? expires_at : nil @@ -87,4 +111,12 @@ class DeployToken < ApplicationRecord def default_username "gitlab+deploy-token-#{id}" if persisted? end + + def no_groups + errors.add(:deploy_token, 'cannot have groups assigned') if group_deploy_tokens.any? + end + + def no_projects + errors.add(:deploy_token, 'cannot have projects assigned') if project_deploy_tokens.any? + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index e0daf692665..fe42fb93633 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -18,6 +18,8 @@ class Deployment < ApplicationRecord has_many :merge_requests, through: :deployment_merge_requests + has_one :deployment_cluster + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project end @@ -28,6 +30,7 @@ class Deployment < ApplicationRecord validate :valid_ref, on: :create delegate :name, to: :environment, prefix: true + delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true scope :for_environment, -> (environment) { where(environment_id: environment) } scope :for_environment_name, -> (name) do @@ -37,6 +40,10 @@ class Deployment < ApplicationRecord scope :for_status, -> (status) { where(status: status) } scope :visible, -> { where(status: %i[running success failed canceled]) } + scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success } + scope :active, -> { where(status: %i[created running]) } + scope :older_than, -> (deployment) { where('id < ?', deployment.id) } + scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') } state_machine :status, initial: :created do event :run do @@ -70,6 +77,14 @@ class Deployment < ApplicationRecord Deployments::FinishedWorker.perform_async(id) end end + + after_transition any => :running do |deployment| + next unless deployment.project.forward_deployment_enabled? + + deployment.run_after_commit do + Deployments::ForwardDeploymentWorker.perform_async(id) + end + end end enum status: { diff --git a/app/models/deployment_cluster.rb b/app/models/deployment_cluster.rb new file mode 100644 index 00000000000..3390d397bad --- /dev/null +++ b/app/models/deployment_cluster.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DeploymentCluster < ApplicationRecord + belongs_to :deployment, optional: false + belongs_to :cluster, optional: false, class_name: 'Clusters::Cluster' +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 939d8bc4bef..e3df61dadae 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -161,7 +161,7 @@ class DiffNote < Note def positions_complete return if self.original_position.complete? && self.position.complete? - errors.add(:position, "is invalid") + errors.add(:position, "is incomplete") end def keep_around_commits diff --git a/app/models/environment.rb b/app/models/environment.rb index 2d480345b5a..bb41c4a066e 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -6,11 +6,14 @@ class Environment < ApplicationRecord self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds + self.reactive_cache_hard_limit = 10.megabytes belongs_to :project, required: true has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :successful_deployments, -> { success }, class_name: 'Deployment' + has_many :active_deployments, -> { active }, class_name: 'Deployment' + has_many :prometheus_alerts, inverse_of: :environment has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' @@ -59,6 +62,7 @@ class Environment < ApplicationRecord scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } scope :preload_cluster, -> { preload(last_deployment: :cluster) } + scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) } ## # Search environments which have names like the given query. @@ -105,6 +109,52 @@ class Environment < ApplicationRecord find_or_create_by(name: name) end + class << self + ## + # This method returns stop actions (jobs) for multiple environments within one + # query. It's useful to avoid N+1 problem. + # + # NOTE: The count of environments should be small~medium (e.g. < 5000) + def stop_actions + cte = cte_for_deployments_with_stop_action + ci_builds = Ci::Build.arel_table + + inner_join_stop_actions = ci_builds.join(cte.table).on( + ci_builds[:project_id].eq(cte.table[:project_id]) + .and(ci_builds[:ref].eq(cte.table[:ref])) + .and(ci_builds[:name].eq(cte.table[:on_stop])) + ).join_sources + + pipeline_ids = ci_builds.join(cte.table).on( + ci_builds[:id].eq(cte.table[:deployable_id]) + ).project(:commit_id) + + Ci::Build.joins(inner_join_stop_actions) + .with(cte.to_arel) + .where(ci_builds[:commit_id].in(pipeline_ids)) + .where(status: HasStatus::BLOCKED_STATUS) + .preload_project_and_pipeline_project + .preload(:user, :metadata, :deployment) + end + + private + + def cte_for_deployments_with_stop_action + Gitlab::SQL::CTE.new(:deployments_with_stop_action, + Deployment.where(environment_id: select(:id)) + .distinct_on_environment + .stoppable) + end + end + + def clear_prometheus_reactive_cache!(query_name) + cluster_prometheus_adapter&.clear_prometheus_reactive_cache!(query_name, self) + end + + def cluster_prometheus_adapter + @cluster_prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, deployment_platform&.cluster).cluster_prometheus_adapter + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) diff --git a/app/models/epic.rb b/app/models/epic.rb index 1203c6c1fc3..ea4a231931d 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -5,8 +5,6 @@ class Epic < ApplicationRecord include IgnorableColumns - ignore_column :milestone_id, remove_after: '2020-02-01', remove_with: '12.8' - def self.link_reference_pattern nil end diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index a904cf4ac46..d328a609439 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -27,6 +27,8 @@ module ErrorTracking validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true + validates :enabled, inclusion: { in: [true, false] } + validates :api_url, presence: { message: 'is a required field' }, if: :enabled validate :validate_api_url_path, if: :enabled @@ -73,7 +75,9 @@ module ErrorTracking end def sentry_client - Sentry::Client.new(api_url, token) + strong_memoize(:sentry_client) do + Sentry::Client.new(api_url, token) + end end def sentry_external_url @@ -87,7 +91,9 @@ module ErrorTracking end def list_sentry_projects - { projects: sentry_client.projects } + handle_exceptions do + { projects: sentry_client.projects } + end end def issue_details(opts = {}) diff --git a/app/models/event.rb b/app/models/event.rb index 9611019adb8..606c4d8302f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -4,6 +4,8 @@ class Event < ApplicationRecord include Sortable include FromUnion include Presentable + include DeleteWithLimit + include CreatedAtFilterable default_scope { reorder(nil) } @@ -76,6 +78,7 @@ class Event < ApplicationRecord # Scopes scope :recent, -> { reorder(id: :desc) } scope :code_push, -> { where(action: PUSHED) } + scope :merged, -> { where(action: MERGED) } scope :with_associations, -> do # We're using preload for "push_event_payload" as otherwise the association @@ -145,10 +148,8 @@ class Event < ApplicationRecord Ability.allowed?(user, :read_issue, note? ? note_target : target) elsif merge_request? || merge_request_note? Ability.allowed?(user, :read_merge_request, note? ? note_target : target) - elsif personal_snippet_note? - Ability.allowed?(user, :read_personal_snippet, note_target) - elsif project_snippet_note? - Ability.allowed?(user, :read_project_snippet, note_target) + elsif personal_snippet_note? || project_snippet_note? + Ability.allowed?(user, :read_snippet, note_target) elsif milestone? Ability.allowed?(user, :read_milestone, project) else diff --git a/app/models/group.rb b/app/models/group.rb index b642b177df1..ea5d46e23f4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -467,6 +467,10 @@ class Group < Namespace import_export_upload&.export_file end + def adjourned_deletion? + false + end + private def update_two_factor_requirement diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb new file mode 100644 index 00000000000..221a7d768ae --- /dev/null +++ b/app/models/group_deploy_token.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class GroupDeployToken < ApplicationRecord + belongs_to :group, class_name: '::Group' + belongs_to :deploy_token, inverse_of: :group_deploy_tokens + + validates :deploy_token, presence: true + validates :group, presence: true + validates :deploy_token_id, uniqueness: { scope: [:group_id] } + + def has_access_to?(requested_project) + return false unless Feature.enabled?(:allow_group_deploy_token, default: true) + + requested_project_group = requested_project&.group + return false unless requested_project_group + return true if requested_project_group.id == group_id + + requested_project_group + .ancestors + .where(id: group_id) + .exists? + end +end diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 5a0d9b08cb0..58c188369da 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -10,11 +10,11 @@ class GroupGroupLink < ApplicationRecord validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id], message: _('The group has already been shared with this group') } validates :shared_with_group, presence: true - validates :group_access, inclusion: { in: Gitlab::Access.values }, + validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true def self.access_options - Gitlab::Access.options + Gitlab::Access.options_with_owner end def self.default_access diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index df0e7b30f84..03f1797f4f4 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -3,6 +3,8 @@ class WebHookLog < ApplicationRecord include SafeUrl include Presentable + include DeleteWithLimit + include CreatedAtFilterable belongs_to :web_hook @@ -23,6 +25,10 @@ class WebHookLog < ApplicationRecord response_status =~ /^2/ end + def internal_error? + response_status == WebHookService::InternalErrorResponse::ERROR_MESSAGE + end + private def obfuscate_basic_auth diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb new file mode 100644 index 00000000000..bf57c5b883f --- /dev/null +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module IncidentManagement + class ProjectIncidentManagementSetting < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + belongs_to :project + + validate :issue_template_exists, if: :create_issue? + + def available_issue_templates + Gitlab::Template::IssueTemplate.all(project) + end + + def issue_template_content + strong_memoize(:issue_template_content) do + issue_template&.content if issue_template_key.present? + end + end + + private + + def issue_template_exists + return unless issue_template_key.present? + + errors.add(:issue_template_key, 'not found') unless issue_template + end + + def issue_template + Gitlab::Template::IssueTemplate.find(issue_template_key, project) + rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + end + end +end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 8d3eeaf2461..3e8d0c6a778 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -21,7 +21,7 @@ class InternalId < ApplicationRecord belongs_to :project belongs_to :namespace - enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5 } + enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 } validates :usage, presence: true diff --git a/app/models/issue.rb b/app/models/issue.rb index bf600278162..be702134ced 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -33,9 +33,6 @@ class Issue < ApplicationRecord has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) } - has_many :issue_milestones - has_many :milestones, through: :issue_milestones - has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :merge_requests_closing_issues, @@ -45,7 +42,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings - has_many :user_mentions, class_name: "IssueUserMention" + has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_one :sentry_issue accepts_nested_attributes_for :sentry_issue @@ -147,6 +144,20 @@ class Issue < ApplicationRecord 'project_id' end + def self.simple_sorts + super.merge( + { + 'closest_future_date' => -> { order_closest_future_date }, + 'closest_future_date_asc' => -> { order_closest_future_date }, + 'due_date' => -> { order_due_date_asc.with_order_id_desc }, + 'due_date_asc' => -> { order_due_date_asc.with_order_id_desc }, + 'due_date_desc' => -> { order_due_date_desc.with_order_id_desc }, + 'relative_position' => -> { order_relative_position_asc.with_order_id_desc }, + 'relative_position_asc' => -> { order_relative_position_asc.with_order_id_desc } + } + ) + end + def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date @@ -158,8 +169,10 @@ class Issue < ApplicationRecord end end - def self.order_by_position_and_priority - order_labels_priority + # `with_cte` argument allows sorting when using CTE queries and prevents + # errors in postgres when using CTE search optimisation + def self.order_by_position_and_priority(with_cte: false) + order_labels_priority(with_cte: with_cte) .reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'), Gitlab::Database.nulls_last_order('highest_priority', 'ASC'), "id DESC") @@ -173,7 +186,7 @@ class Issue < ApplicationRecord def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" end def suggested_branch_name diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index 748f73373e3..8128b8a538e 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -3,6 +3,8 @@ class IssueAssignee < ApplicationRecord belongs_to :issue belongs_to :assignee, class_name: "User", foreign_key: :user_id + + validates :assignee, uniqueness: { scope: :issue_id } end IssueAssignee.prepend_if_ee('EE::IssueAssignee') diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb deleted file mode 100644 index da030077d87..00000000000 --- a/app/models/issue_milestone.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class IssueMilestone < ApplicationRecord - belongs_to :milestone - belongs_to :issue -end diff --git a/app/models/key.rb b/app/models/key.rb index 71188f210bb..e729ef67346 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -96,8 +96,7 @@ class Key < ApplicationRecord def remove_from_shell GitlabShellWorker.perform_async( :remove_key, - shell_id, - key + shell_id ) end diff --git a/app/models/label.rb b/app/models/label.rb index dbb96a2b9da..632207701d8 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -42,6 +42,22 @@ class Label < ApplicationRecord scope :order_name_desc, -> { reorder(title: :desc) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } + scope :top_labels_by_target, -> (target_relation) { + label_id_column = arel_table[:id] + + # Window aggregation to count labels + count_by_id = Arel::Nodes::Over.new( + Arel::Nodes::NamedFunction.new('count', [label_id_column]), + Arel::Nodes::Window.new.partition(label_id_column) + ).as('count_by_id') + + select(arel_table[Arel.star], count_by_id) + .joins(:label_links) + .merge(LabelLink.where(target: target_relation)) + .reorder(count_by_id: :desc) + .distinct + } + def self.prioritized(project) joins(:priorities) .where(label_priorities: { project_id: project }) @@ -225,7 +241,7 @@ class Label < ApplicationRecord reference = "#{self.class.reference_prefix}#{format_reference}" if from - "#{from.to_reference(target_project, full: full)}#{reference}" + "#{from.to_reference_base(target_project, full: full)}#{reference}" else reference end diff --git a/app/models/label_link.rb b/app/models/label_link.rb index ffc0afd8e85..5ae1e88e14e 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class LabelLink < ApplicationRecord + include BulkInsertSafe include Importable belongs_to :target, polymorphic: true, inverse_of: :label_links # rubocop:disable Cop/PolymorphicAssociations diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index e45c56b6394..68ef84223c5 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -16,6 +16,8 @@ class LfsObjectsProject < ApplicationRecord design: 2 ## EE-specific } + scope :project_id_in, ->(ids) { where(project_id: ids) } + private def update_project_statistics diff --git a/app/models/member.rb b/app/models/member.rb index 2654453cf3f..a26a0615a6e 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -75,6 +75,7 @@ class Member < ApplicationRecord scope :reporters, -> { active.where(access_level: REPORTER) } scope :developers, -> { active.where(access_level: DEVELOPER) } scope :maintainers, -> { active.where(access_level: MAINTAINER) } + scope :non_guests, -> { where('members.access_level > ?', GUEST) } scope :masters, -> { maintainers } # @deprecated scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } @@ -82,6 +83,7 @@ class Member < ApplicationRecord scope :with_user, -> (user) { where(user: user) } scope :with_source_id, ->(source_id) { where(source_id: source_id) } + scope :including_source, -> { includes(:source) } scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7162ba08a76..6c32bdadfa8 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -24,6 +24,7 @@ class MergeRequest < ApplicationRecord self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_hard_limit = 20.megabytes SORTING_PREFERENCE_FIELD = :merge_requests_sort @@ -34,9 +35,8 @@ class MergeRequest < ApplicationRecord has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } has_many :merge_request_diffs - - has_many :merge_request_milestones - has_many :milestones, through: :merge_request_milestones + has_many :merge_request_context_commits + has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request @@ -74,7 +74,7 @@ class MergeRequest < ApplicationRecord has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees - has_many :user_mentions, class_name: "MergeRequestUserMention" + has_many :user_mentions, class_name: "MergeRequestUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :deployment_merge_requests @@ -160,20 +160,25 @@ class MergeRequest < ApplicationRecord state_machine :merge_status, initial: :unchecked do event :mark_as_unchecked do - transition [:can_be_merged, :unchecked] => :unchecked + transition [:can_be_merged, :checking, :unchecked] => :unchecked transition [:cannot_be_merged, :cannot_be_merged_recheck] => :cannot_be_merged_recheck end + event :mark_as_checking do + transition [:unchecked, :cannot_be_merged_recheck] => :checking + end + event :mark_as_mergeable do - transition [:unchecked, :cannot_be_merged_recheck] => :can_be_merged + transition [:unchecked, :cannot_be_merged_recheck, :checking] => :can_be_merged end event :mark_as_unmergeable do - transition [:unchecked, :cannot_be_merged_recheck] => :cannot_be_merged + transition [:unchecked, :cannot_be_merged_recheck, :checking] => :cannot_be_merged end state :unchecked state :cannot_be_merged_recheck + state :checking state :can_be_merged state :cannot_be_merged @@ -191,7 +196,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def check_state?(merge_status) - [:unchecked, :cannot_be_merged_recheck].include?(merge_status.to_sym) + [:unchecked, :cannot_be_merged_recheck, :checking].include?(merge_status.to_sym) end end @@ -223,6 +228,9 @@ class MergeRequest < ApplicationRecord scope :by_merge_commit_sha, -> (sha) do where(merge_commit_sha: sha) end + scope :by_cherry_pick_sha, -> (sha) do + joins(:notes).where(notes: { commit_id: sha }) + end scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } scope :with_api_entity_associations, -> { @@ -388,7 +396,11 @@ class MergeRequest < ApplicationRecord def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" + end + + def context_commits + @context_commits ||= merge_request_context_commits.map(&:to_commit) end def commits(limit: nil) @@ -698,7 +710,7 @@ class MergeRequest < ApplicationRecord end def validate_branch_name(attr) - return unless changes_include?(attr) + return unless will_save_change_to_attribute?(attr) branch = read_attribute(attr) @@ -812,13 +824,23 @@ class MergeRequest < ApplicationRecord MergeRequests::ReloadDiffsService.new(self, current_user).execute end - def check_mergeability - return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status? + def check_mergeability(async: false) + return unless recheck_merge_status? - MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false) + check_service = MergeRequests::MergeabilityCheckService.new(self) + + if async && Feature.enabled?(:async_merge_request_check_mergeability, project) + check_service.async_execute + else + check_service.execute(retry_lease: false) + end end # rubocop: enable CodeReuse/ServiceClass + def diffable_merge_ref? + Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present? + end + # Returns boolean indicating the merge_status should be rechecked in order to # switch to either can_be_merged or cannot_be_merged. def recheck_merge_status? @@ -1142,7 +1164,7 @@ class MergeRequest < ApplicationRecord # Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`), # we cannot look up environments with source branch name. def environments - return Environment.none unless actual_head_pipeline&.triggered_by_merge_request? + return Environment.none unless actual_head_pipeline&.merge_request? actual_head_pipeline.environments end @@ -1187,12 +1209,10 @@ class MergeRequest < ApplicationRecord end def in_locked_state - begin - lock_mr - yield - ensure - unlock_mr - end + lock_mr + yield + ensure + unlock_mr end def diverged_commits_count diff --git a/app/models/merge_request/pipelines.rb b/app/models/merge_request/pipelines.rb index c32f29a9304..72756e8e9d0 100644 --- a/app/models/merge_request/pipelines.rb +++ b/app/models/merge_request/pipelines.rb @@ -61,6 +61,8 @@ class MergeRequest::Pipelines pipelines.joins(shas_table) end + # NOTE: this method returns only parent merge request pipelines. + # Child merge request pipelines have a different source. def triggered_by_merge_request source_project.ci_pipelines .where(source: :merge_request_event, merge_request: merge_request) diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index f0e6be51b7f..fe642bee8e2 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -3,4 +3,6 @@ class MergeRequestAssignee < ApplicationRecord belongs_to :merge_request belongs_to :assignee, class_name: "User", foreign_key: :user_id + + validates :assignee, uniqueness: { scope: :merge_request_id } end diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb new file mode 100644 index 00000000000..eecb10e6dbc --- /dev/null +++ b/app/models/merge_request_context_commit.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class MergeRequestContextCommit < ApplicationRecord + include CachedCommit + include ShaAttribute + + belongs_to :merge_request + has_many :diff_files, class_name: 'MergeRequestContextCommitDiffFile' + + sha_attribute :sha + + validates :sha, presence: true + validates :sha, uniqueness: { message: 'has already been added' } + + # delete all MergeRequestContextCommit & MergeRequestContextCommitDiffFile for given merge_request & commit SHAs + def self.delete_bulk(merge_request, commits) + commit_ids = commits.map(&:sha) + merge_request.merge_request_context_commits.where(sha: commit_ids).delete_all + end + + # create MergeRequestContextCommit by given commit sha and it's diff file record + def self.bulk_insert(*args) + Gitlab::Database.bulk_insert('merge_request_context_commits', *args) + end + + def to_commit + # Here we are storing the commit sha because to_hash removes the sha parameter and we lose + # the reference, this happens because we are storing the ID in db and the Commit class replaces + # id with sha and removes it, so in our case it will be some incremented integer which is not + # what we want + commit_hash = attributes.except('id').to_hash + commit_hash['id'] = sha + Commit.from_hash(commit_hash, merge_request.target_project) + end +end diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb new file mode 100644 index 00000000000..9dce7c53ab6 --- /dev/null +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MergeRequestContextCommitDiffFile < ApplicationRecord + include Gitlab::EncodingHelper + include ShaAttribute + include DiffFile + + belongs_to :merge_request_context_commit, inverse_of: :diff_files + + sha_attribute :sha + alias_attribute :id, :sha + + # create MergeRequestContextCommitDiffFile by given diff file record(s) + def self.bulk_insert(*args) + Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args) + end +end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index fa633a1a725..ffe95e8f034 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -560,6 +560,10 @@ class MergeRequestDiff < ApplicationRecord opening_external_diff do collection = merge_request_diff_files + if options[:include_context_commits] + collection += merge_request.merge_request_context_commit_diff_files + end + if paths = options[:paths] collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) end diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index b897bbc8cf5..460b394f067 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class MergeRequestDiffCommit < ApplicationRecord + include BulkInsertSafe include ShaAttribute + include CachedCommit belongs_to :merge_request_diff @@ -9,8 +11,6 @@ class MergeRequestDiffCommit < ApplicationRecord alias_attribute :id, :sha def self.create_bulk(merge_request_diff_id, commits) - sha_attribute = Gitlab::Database::ShaAttribute.new - rows = commits.map.with_index do |commit, index| # See #parent_ids. commit_hash = commit.to_hash.except(:parent_ids) @@ -19,7 +19,7 @@ class MergeRequestDiffCommit < ApplicationRecord commit_hash.merge( merge_request_diff_id: merge_request_diff_id, relative_order: index, - sha: sha_attribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize + sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) ) @@ -27,16 +27,4 @@ class MergeRequestDiffCommit < ApplicationRecord Gitlab::Database.bulk_insert(self.table_name, rows) end - - def to_hash - Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash| - hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend - end - end - - # We don't save these, because they would need a table or a serialised - # field. They aren't used anywhere, so just pretend the commit has no parents. - def parent_ids - [] - end end diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index 14c86ec69da..23319445a38 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class MergeRequestDiffFile < ApplicationRecord + include BulkInsertSafe include Gitlab::EncodingHelper include DiffFile diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb deleted file mode 100644 index 4fa1d1dcb33..00000000000 --- a/app/models/merge_request_milestone.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class MergeRequestMilestone < ApplicationRecord - belongs_to :milestone - belongs_to :merge_request -end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 5da92fc4bc5..29c621c54d0 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -39,9 +39,6 @@ class Milestone < ApplicationRecord has_many :merge_requests has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - has_many :issue_milestones - has_many :merge_request_milestones - scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_groups, ->(ids) { where(group_id: ids) } scope :active, -> { with_state(:active) } @@ -59,6 +56,12 @@ class Milestone < ApplicationRecord where(project_id: projects).or(where(group_id: groups)) end + scope :within_timeframe, -> (start_date, end_date) do + where('start_date is not NULL or due_date is not NULL') + .where('start_date is NULL or start_date <= ?', end_date) + .where('due_date is NULL or due_date >= ?', start_date) + end + scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) } @@ -228,7 +231,7 @@ class Milestone < ApplicationRecord reference = "#{self.class.reference_prefix}#{format_reference}" if project - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" else reference end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 621a98e9ab6..efe14a3e614 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -131,6 +131,11 @@ class Namespace < ApplicationRecord name = host.delete_suffix(gitlab_host) Namespace.find_by_full_path(name) end + + # overridden in ee + def reset_ci_minutes!(namespace_id) + false + end end def visibility_level_field diff --git a/app/models/note.rb b/app/models/note.rb index 11237a5049d..97e84bb79f6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -124,7 +124,7 @@ class Note < ApplicationRecord scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, - :system_note_metadata, :note_diff_file, :suggestions) + { system_note_metadata: :description_version }, :note_diff_file, :suggestions) end scope :with_notes_filter, -> (notes_filter) do @@ -157,6 +157,7 @@ class Note < ApplicationRecord after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache + after_save :store_mentions!, if: :any_mentionable_attributes_changed? class << self def model_name @@ -367,7 +368,7 @@ class Note < ApplicationRecord end def noteable_ability_name - for_snippet? ? noteable.class.name.underscore : noteable_type.demodulize.underscore + for_snippet? ? 'snippet' : noteable_type.demodulize.underscore end def can_be_discussion_note? @@ -498,6 +499,8 @@ class Note < ApplicationRecord end def user_mentions + return Note.none unless noteable.present? + noteable.user_mentions.where(note: self) end @@ -506,6 +509,8 @@ class Note < ApplicationRecord # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. def model_user_mention + return if user_mentions.is_a?(ActiveRecord::NullRelation) + user_mentions.first_or_initialize end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 2b3443f24d7..e2c362538eb 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -21,9 +21,14 @@ class NotificationSetting < ApplicationRecord # pending delete). # scope :for_projects, -> do - includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil, pending_delete: true }) + includes(:project).references(:projects) + .where(source_type: 'Project') + .where.not(projects: { id: nil }) + .where.not(projects: { pending_delete: true }) end + scope :preload_source_route, -> { preload(source: [:route]) } + EMAIL_EVENTS = [ :new_release, :new_note, diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index dd2cafd9a35..05cf427184c 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -6,13 +6,15 @@ class PagesDomain < ApplicationRecord SSL_RENEWAL_THRESHOLD = 30.days.freeze enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate - enum domain_type: { instance: 0, group: 1, project: 2 }, _prefix: :domain_type + enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope + enum usage: { pages: 0, serverless: 1 }, _prefix: :usage belongs_to :project has_many :acme_orders, class_name: "PagesDomainAcmeOrder" validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } + validates :certificate, :key, presence: true, if: :usage_serverless? validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, if: :certificate_should_be_present? validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? } @@ -26,8 +28,9 @@ class PagesDomain < ApplicationRecord validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } - default_value_for :domain_type, allow_nil: false, value: :project + default_value_for :scope, allow_nil: false, value: :project default_value_for :wildcard, allow_nil: false, value: false + default_value_for :usage, allow_nil: false, value: :pages attr_encrypted :key, mode: :per_attribute_iv_and_salt, @@ -60,6 +63,10 @@ class PagesDomain < ApplicationRecord scope :for_removal, -> { where("remove_at < ?", Time.now) } + scope :with_logging_info, -> { includes(project: [:namespace, :route]) } + + scope :instance_serverless, -> { where(wildcard: true, scope: :instance, usage: :serverless) } + def verified? !!verified_at end @@ -220,7 +227,7 @@ class PagesDomain < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def update_daemon - return if domain_type_instance? + return if usage_serverless? ::Projects::UpdatePagesConfigurationService.new(project).execute end @@ -283,3 +290,5 @@ class PagesDomain < ApplicationRecord !auto_ssl_enabled? && project&.pages_https_only? end end + +PagesDomain.prepend_if_ee('::EE::PagesDomain') diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb new file mode 100644 index 00000000000..5f2df444fd0 --- /dev/null +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PerformanceMonitoring + class PrometheusDashboard + include ActiveModel::Model + + attr_accessor :dashboard, :panel_groups + + validates :dashboard, presence: true + validates :panel_groups, presence: true + + def self.from_json(json_content) + dashboard = new( + dashboard: json_content['dashboard'], + panel_groups: json_content['panel_groups'].map { |group| PrometheusPanelGroup.from_json(group) } + ) + + dashboard.tap(&:validate!) + end + + def to_yaml + self.as_json(only: valid_attributes).to_yaml + end + + private + + def valid_attributes + %w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard) + end + end +end diff --git a/app/models/performance_monitoring/prometheus_metric.rb b/app/models/performance_monitoring/prometheus_metric.rb new file mode 100644 index 00000000000..7b8bef906fa --- /dev/null +++ b/app/models/performance_monitoring/prometheus_metric.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PerformanceMonitoring + class PrometheusMetric + include ActiveModel::Model + + attr_accessor :id, :unit, :label, :query, :query_range + + validates :unit, presence: true + validates :query, presence: true, unless: :query_range + validates :query_range, presence: true, unless: :query + + def self.from_json(json_content) + metric = PrometheusMetric.new( + id: json_content['id'], + unit: json_content['unit'], + label: json_content['label'], + query: json_content['query'], + query_range: json_content['query_range'] + ) + + metric.tap(&:validate!) + end + end +end diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb new file mode 100644 index 00000000000..c03218b4219 --- /dev/null +++ b/app/models/performance_monitoring/prometheus_panel.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PerformanceMonitoring + class PrometheusPanel + include ActiveModel::Model + + attr_accessor :type, :title, :y_label, :weight, :metrics + + validates :title, presence: true + validates :metrics, presence: true + + def self.from_json(json_content) + panel = new( + type: json_content['type'], + title: json_content['title'], + y_label: json_content['y_label'], + weight: json_content['weight'], + metrics: json_content['metrics'].map { |metric| PrometheusMetric.from_json(metric) } + ) + + panel.tap(&:validate!) + end + end +end diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb new file mode 100644 index 00000000000..e672545fce3 --- /dev/null +++ b/app/models/performance_monitoring/prometheus_panel_group.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PerformanceMonitoring + class PrometheusPanelGroup + include ActiveModel::Model + + attr_accessor :group, :priority, :panels + + validates :group, presence: true + validates :panels, presence: true + + def self.from_json(json_content) + panel_group = new( + group: json_content['group'], + priority: json_content['priority'], + panels: json_content['panels'].map { |panel| PrometheusPanel.from_json(panel) } + ) + + panel_group.tap(&:validate!) + end + end +end diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 1b5be8698b1..5940265b17a 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -2,4 +2,8 @@ class PersonalSnippet < Snippet include WithUploads + + def web_url(only_path: nil) + Gitlab::Routing.url_helpers.snippet_url(self, only_path: only_path) + end end diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 25eab6e4e03..94992adfd1e 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -110,8 +110,8 @@ class PoolRepository < ApplicationRecord end def storage - Storage::HashedProject - .new(self, prefix: Storage::HashedProject::POOL_PATH_PREFIX) + Storage::Hashed + .new(self, prefix: Storage::Hashed::POOL_PATH_PREFIX) end end diff --git a/app/models/project.rb b/app/models/project.rb index b2f20731c65..e16bd568153 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -9,7 +9,6 @@ class Project < ApplicationRecord include AccessRequestable include Avatarable include CacheMarkdownField - include Referable include Sortable include AfterCommitQueue include CaseSensitivity @@ -19,6 +18,7 @@ class Project < ApplicationRecord include ProjectFeaturesCompatibility include SelectForProjectAuthorization include Presentable + include HasRepository include Routable include GroupDescendant include Gitlab::SQL::Pattern @@ -137,6 +137,7 @@ class Project < ApplicationRecord has_many :boards # Project services + has_one :alerts_service has_one :campfire_service has_one :discord_service has_one :drone_ci_service @@ -186,9 +187,11 @@ class Project < ApplicationRecord has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :project_repository, inverse_of: :project + has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' has_one :grafana_integration, inverse_of: :project + has_one :project_setting, ->(project) { where_or_create_by(project: project) }, inverse_of: :project # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -243,6 +246,7 @@ class Project < ApplicationRecord has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project has_many :prometheus_metrics + has_many :prometheus_alerts, inverse_of: :project # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -313,20 +317,21 @@ class Project < ApplicationRecord allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true + accepts_nested_attributes_for :prometheus_service, update_only: true delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?, :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, + :repository_access_level, :pages_access_level, to: :project_feature, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true - delegate :base_dir, :disk_path, to: :storage delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true @@ -338,6 +343,7 @@ class Project < ApplicationRecord delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci + delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings # Validations validates :creator, presence: true, on: :create @@ -357,6 +363,8 @@ class Project < ApplicationRecord project_path: true, length: { maximum: 255 } + validates :project_feature, presence: true + validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, @@ -394,9 +402,11 @@ class Project < ApplicationRecord # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) } - scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) } - scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) } + scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } + scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) } scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) } + # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name + scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } @@ -411,6 +421,8 @@ class Project < ApplicationRecord scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } scope :inc_routes, -> { includes(:route, namespace: :route) } scope :with_statistics, -> { includes(:statistics) } + scope :with_namespace, -> { includes(:namespace) } + scope :with_import_state, -> { includes(:import_state) } scope :with_service, ->(service) { joins(service).eager_load(service) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } scope :with_container_registry, -> { where(container_registry_enabled: true) } @@ -449,6 +461,9 @@ class Project < ApplicationRecord scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } + scope :with_issues_or_mrs_available_for_user, -> (user) do + with_issues_available_for_user(user).or(with_merge_requests_available_for_user(user)) + end scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } scope :with_limit, -> (maximum) { limit(maximum) } @@ -541,6 +556,11 @@ class Project < ApplicationRecord ) end + def self.wrap_with_cte(collection) + cte = Gitlab::SQL::CTE.new(:projects_cte, collection) + Project.with(cte.to_arel).from(cte.alias_to(Project.arel_table)) + end + scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } @@ -582,9 +602,9 @@ class Project < ApplicationRecord # pass a string to avoid AR adding the table name reorder('project_statistics.storage_size DESC, projects.id DESC') when 'latest_activity_desc' - reorder(last_activity_at: :desc) + reorder(self.arel_table['last_activity_at'].desc) when 'latest_activity_asc' - reorder(last_activity_at: :asc) + reorder(self.arel_table['last_activity_at'].asc) when 'stars_desc' sorted_by_stars_desc when 'stars_asc' @@ -751,8 +771,8 @@ class Project < ApplicationRecord Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true) end - def empty_repo? - repository.empty? + def context_commits_enabled? + Feature.enabled?(:context_commits, default_enabled: true) end def team @@ -782,18 +802,6 @@ class Project < ApplicationRecord has_root_container_repository_tags? end - def commit(ref = 'HEAD') - repository.commit(ref) - end - - def commit_by(oid:) - repository.commit_by(oid: oid) - end - - def commits_by(oids:) - repository.commits_by(oids: oids) - end - # ref can't be HEAD, can only be branch/tag name def latest_successful_build_for_ref(job_name, ref = default_branch) return unless ref @@ -894,7 +902,9 @@ class Project < ApplicationRecord if Gitlab::UrlSanitizer.valid?(value) import_url = Gitlab::UrlSanitizer.new(value) super(import_url.sanitized_url) - create_or_update_import_data(credentials: import_url.credentials) + + credentials = import_url.credentials.to_h.transform_values { |value| CGI.unescape(value.to_s) } + create_or_update_import_data(credentials: credentials) else super(value) end @@ -1056,12 +1066,19 @@ class Project < ApplicationRecord end end - def to_reference_with_postfix - "#{to_reference(full: true)}#{self.class.reference_postfix}" + # Produce a valid reference (see Referable#to_reference) + # + # NB: For projects, all references are 'full' - i.e. they all include the + # full_path, rather than just the project name. For this reason, we ignore + # the value of `full:` passed to this method, which is part of the Referable + # interface. + def to_reference(from = nil, full: false) + base = to_reference_base(from, full: true) + "#{base}#{self.class.reference_postfix}" end # `from` argument can be a Namespace or Project. - def to_reference(from = nil, full: false) + def to_reference_base(from = nil, full: false) if full || cross_namespace_reference?(from) full_path elsif cross_project_reference?(from) @@ -1334,48 +1351,6 @@ class Project < ApplicationRecord services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend end - def valid_repo? - repository.exists? - rescue - errors.add(:path, _('Invalid repository path')) - false - end - - def url_to_repo - gitlab_shell.url_to_repo(full_path) - end - - def repo_exists? - strong_memoize(:repo_exists) do - repository.exists? - rescue - false - end - end - - def root_ref?(branch) - repository.root_ref == branch - end - - def ssh_url_to_repo - url_to_repo - end - - def http_url_to_repo - custom_root = Gitlab::CurrentSettings.custom_http_clone_url_root - - project_url = if custom_root.present? - Gitlab::Utils.append_path( - custom_root, - web_url(only_path: true) - ) - else - web_url - end - - "#{project_url}.git" - end - # Is overridden in EE def lfs_http_url_to_repo(_) http_url_to_repo @@ -1391,6 +1366,10 @@ class Project < ApplicationRecord forked_from_project || fork_network&.root_project end + # TODO: Remove this method once all LfsObjectsProject records are backfilled + # for forks. + # + # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info. def lfs_storage_project @lfs_storage_project ||= begin result = self @@ -1403,14 +1382,27 @@ class Project < ApplicationRecord end end - # This will return all `lfs_objects` that are accessible to the project. - # So this might be `self.lfs_objects` if the project is not part of a fork - # network, or it is the base of the fork network. + # This will return all `lfs_objects` that are accessible to the project and + # the fork source. This is needed since older forks won't have access to some + # LFS objects directly and have to get it from the fork source. # - # TODO: refactor this to get the correct lfs objects when implementing - # https://gitlab.com/gitlab-org/gitlab-foss/issues/39769 + # TODO: Remove this method once all LfsObjectsProject records are backfilled + # for forks. At that point, projects can look at their own `lfs_objects`. + # + # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info. def all_lfs_objects - lfs_storage_project.lfs_objects + LfsObject + .distinct + .joins(:lfs_objects_projects) + .where(lfs_objects_projects: { project_id: [self, lfs_storage_project] }) + end + + # TODO: Call `#lfs_objects` instead once all LfsObjectsProject records are + # backfilled. At that point, projects can look at their own `lfs_objects`. + # + # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info. + def lfs_objects_oids + all_lfs_objects.pluck(:oid) end def personal? @@ -1515,15 +1507,6 @@ class Project < ApplicationRecord end end - def default_branch - @default_branch ||= repository.root_ref - end - - def reload_default_branch - @default_branch = nil - default_branch - end - def visibility_level_field :visibility_level end @@ -1560,10 +1543,6 @@ class Project < ApplicationRecord create_repository(force: true) unless repository_exists? end - def repository_exists? - !!repository.exists? - end - def wiki_repository_exists? wiki.repository_exists? end @@ -1936,6 +1915,8 @@ class Project < ApplicationRecord .append(key: 'GITLAB_CI', value: 'true') .append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url) .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) + .append(key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s) + .append(key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol) .append(key: 'CI_SERVER_NAME', value: 'GitLab') .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) .append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) @@ -2203,7 +2184,7 @@ class Project < ApplicationRecord end def reference_counter(type: Gitlab::GlRepository::PROJECT) - Gitlab::ReferenceCounter.new(type.identifier_for_subject(self)) + Gitlab::ReferenceCounter.new(type.identifier_for_container(self)) end def badges @@ -2263,7 +2244,7 @@ class Project < ApplicationRecord def storage @storage ||= if hashed_storage?(:repository) - Storage::HashedProject.new(self) + Storage::Hashed.new(self) else Storage::LegacyProject.new(self) end @@ -2274,7 +2255,7 @@ class Project < ApplicationRecord end def snippets_visible?(user = nil) - Ability.allowed?(user, :read_project_snippet, self) + Ability.allowed?(user, :read_snippet, self) end def max_attachment_size @@ -2345,6 +2326,22 @@ class Project < ApplicationRecord false end + def uses_default_ci_config? + ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] + end + + def limited_protected_branches(limit) + protected_branches.limit(limit) + end + + def alerts_service_activated? + alerts_service&.active? + end + + def self_monitoring? + Gitlab::CurrentSettings.self_monitoring_project_id == id + end + private def closest_namespace_setting(name) diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 1dd65c76258..b26a3025b61 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ProjectCiCdSetting < ApplicationRecord - include IgnorableColumns - # https://gitlab.com/gitlab-org/gitlab/issues/36651 - ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22' belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. @@ -21,6 +18,8 @@ class ProjectCiCdSetting < ApplicationRecord }, allow_nil: true + default_value_for :forward_deployment_enabled, true + def self.available? @available ||= ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION @@ -31,6 +30,10 @@ class ProjectCiCdSetting < ApplicationRecord super end + def forward_deployment_enabled? + super && ::Feature.enabled?(:forward_deployment_enabled, project) + end + private def set_default_git_depth diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb index a55667496fb..0bce1c745f7 100644 --- a/app/models/project_deploy_token.rb +++ b/app/models/project_deploy_token.rb @@ -7,4 +7,8 @@ class ProjectDeployToken < ApplicationRecord validates :deploy_token, presence: true validates :project, presence: true validates :deploy_token_id, uniqueness: { scope: [:project_id] } + + def has_access_to?(requested_project) + requested_project == project + end end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index b70c07a8386..bc16a34612a 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -31,6 +31,10 @@ class ProjectGroupLink < ApplicationRecord DEVELOPER end + def self.search(query) + joins(:group).merge(Group.search(query)) + end + def human_access self.class.access_options.key(self.group_access) end diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb new file mode 100644 index 00000000000..2f7902d9617 --- /dev/null +++ b/app/models/project_services/alerts_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'securerandom' + +class AlertsService < Service + has_one :data, class_name: 'AlertsServiceData', autosave: true, + inverse_of: :service, foreign_key: :service_id + + attribute :token, :string + delegate :token, :token=, :token_changed?, :token_was, to: :data + + validates :token, presence: true, if: :activated? + + before_validation :prevent_token_assignment + before_validation :ensure_token, if: :activated? + + def url + url_helpers.project_alerts_notify_url(project, format: :json) + end + + def json_fields + super + %w(token) + end + + def editable? + false + end + + def show_active_box? + false + end + + def can_test? + false + end + + def title + _('Alerts endpoint') + end + + def description + _('Receive alerts on GitLab from any source') + end + + def detailed_description + description + end + + def self.to_param + 'alerts' + end + + def self.supported_events + %w() + end + + def data + super || build_data + end + + private + + def prevent_token_assignment + self.token = token_was if token.present? && token_changed? + end + + def ensure_token + self.token = generate_token if token.blank? + end + + def generate_token + SecureRandom.hex + end + + def url_helpers + Gitlab::Routing.url_helpers + end +end diff --git a/app/models/project_services/alerts_service_data.rb b/app/models/project_services/alerts_service_data.rb new file mode 100644 index 00000000000..5a52ed83455 --- /dev/null +++ b/app/models/project_services/alerts_service_data.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'securerandom' + +class AlertsServiceData < ApplicationRecord + belongs_to :service, class_name: 'AlertsService' + + validates :service, presence: true + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' +end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 529af1277b0..5c39a80b32d 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -16,7 +16,7 @@ module ChatMessage def initialize(params) @markdown = params[:markdown] || false - @project_name = params.dig(:project, :path_with_namespace) || params[:project_name] + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) @project_url = params.dig(:project, :web_url) || params[:project_url] @user_full_name = params.dig(:user, :name) || params[:user_full_name] @user_name = params.dig(:user, :username) || params[:user_name] diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb index 46313ba7bec..dc62a4c8908 100644 --- a/app/models/project_services/chat_message/merge_message.rb +++ b/app/models/project_services/chat_message/merge_message.rb @@ -62,7 +62,7 @@ module ChatMessage end def merge_request_url - "#{project_url}/merge_requests/#{merge_request_iid}" + "#{project_url}/-/merge_requests/#{merge_request_iid}" end # overridden in EE diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index b84a79453c1..46c8260ab48 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -157,7 +157,7 @@ class ChatNotificationService < Service end def project_name - project.full_name.gsub(/\s/, '') + project.full_name end def project_url diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index eb78938324d..dd2f1359e76 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -25,10 +25,9 @@ class EmailsOnPushService < Service end def initialize_properties - if properties.nil? - self.properties = {} - self.branches_to_be_notified ||= "all" - end + super + + self.branches_to_be_notified = 'all' if branches_to_be_notified.nil? end def execute(push_data) diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 094488cb431..e721fded1d9 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -36,7 +36,7 @@ class FlowdockService < Service token: token, repo: project.repository, repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", - commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s", + commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s", diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" ) end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 019bd54f48c..c92e8ecb31c 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -184,7 +184,7 @@ class HipchatService < Service description = obj_attr[:description] title = render_line(obj_attr[:title]) - merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}" + merge_request_url = "#{project_url}/-/merge_requests/#{merge_request_id}" merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>" message = ["#{user_name} #{state} #{merge_request_link} in " \ "#{project_link}: <b>#{title}</b>"] diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 128cbc6fa82..9875e0b9b88 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -194,7 +194,7 @@ class JiraService < IssueTrackerService def test(_) result = test_settings success = result.present? - result = @error if @error && !success + result = @error&.message unless success { success: success, result: result } end @@ -205,6 +205,8 @@ class JiraService < IssueTrackerService nil end + private + def test_settings return unless client_url.present? @@ -212,8 +214,6 @@ class JiraService < IssueTrackerService jira_request { client.ServerInfo.all.attrs } end - private - def can_cross_reference?(noteable) case noteable when Commit then commit_events @@ -346,9 +346,17 @@ class JiraService < IssueTrackerService # Handle errors when doing Jira API calls def jira_request yield - rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e - @error = e.message - log_error("Error sending message", client_url: client_url, error: @error) + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error + @error = error + log_error( + "Error sending message", + client_url: client_url, + error: { + exception_class: error.class.name, + exception_message: error.message, + exception_backtrace: error.backtrace.join("\n") + } + ) nil end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 8452239129d..65bf8535d2a 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -100,6 +100,6 @@ class PipelinesEmailService < Service end def retrieve_recipients(data) - recipients.to_s.split(/[,(?:\r?\n) ]+/).reject(&:empty?) + recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?) end end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 3d5967de41e..00b06ae2595 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -102,7 +102,7 @@ class PrometheusService < MonitoringService private def self_monitoring_project? - project && project.id == current_settings.instance_administration_project_id + project && project.id == current_settings.self_monitoring_project_id end def internal_prometheus_url? diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 0416eaa5be0..02d06eeb405 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -6,9 +6,9 @@ class YoutrackService < IssueTrackerService # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 def self.reference_pattern(only_long: false) if only_long - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)/ + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ else - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/ + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}(?<issue>\d+))/ end end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb new file mode 100644 index 00000000000..37e4a7be770 --- /dev/null +++ b/app/models/project_setting.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ProjectSetting < ApplicationRecord + belongs_to :project, inverse_of: :project_setting + + self.primary_key = :project_id + + def self.where_or_create_by(attrs) + where(primary_key => safe_find_or_create_by(attrs)) + end +end diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index ffb08e10f1f..6045ec71c6e 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -5,4 +5,8 @@ class ProjectSnippet < Snippet validates :project, presence: true validates :secret, inclusion: { in: [false] } + + def web_url(only_path: nil) + Gitlab::Routing.url_helpers.project_snippet_url(project, self, only_path: only_path) + end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f4666197def..1abde5196de 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -65,7 +65,7 @@ class ProjectWiki # Returns the Gitlab::Git::Wiki object. def wiki @wiki ||= begin - gl_repository = Gitlab::GlRepository::WIKI.identifier_for_subject(project) + gl_repository = Gitlab::GlRepository::WIKI.identifier_for_container(project) raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) create_repo!(raw_repository) unless raw_repository.exists? diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb new file mode 100644 index 00000000000..1014231102f --- /dev/null +++ b/app/models/prometheus_alert.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class PrometheusAlert < ApplicationRecord + include Sortable + + OPERATORS_MAP = { + lt: "<", + eq: "==", + gt: ">" + }.freeze + + belongs_to :environment, validate: true, inverse_of: :prometheus_alerts + belongs_to :project, validate: true, inverse_of: :prometheus_alerts + belongs_to :prometheus_metric, validate: true, inverse_of: :prometheus_alerts + + has_many :prometheus_alert_events, inverse_of: :prometheus_alert + has_many :related_issues, through: :prometheus_alert_events + + after_save :clear_prometheus_adapter_cache! + after_destroy :clear_prometheus_adapter_cache! + + validates :environment, :project, :prometheus_metric, presence: true + validate :require_valid_environment_project! + validate :require_valid_metric_project! + + enum operator: { lt: 0, eq: 1, gt: 2 } + + delegate :title, :query, to: :prometheus_metric + + scope :for_metric, -> (metric) { where(prometheus_metric: metric) } + scope :for_project, -> (project) { where(project_id: project) } + scope :for_environment, -> (environment) { where(environment_id: environment) } + + def self.distinct_projects + sub_query = self.group(:project_id).select(1) + self.from(sub_query) + end + + def self.operator_to_enum(op) + OPERATORS_MAP.invert.fetch(op) + end + + def full_query + "#{query} #{computed_operator} #{threshold}" + end + + def computed_operator + OPERATORS_MAP.fetch(operator.to_sym) + end + + def to_param + { + "alert" => title, + "expr" => full_query, + "for" => "5m", + "labels" => { + "gitlab" => "hook", + "gitlab_alert_id" => prometheus_metric_id + } + } + end + + private + + def clear_prometheus_adapter_cache! + environment.clear_prometheus_reactive_cache!(:additional_metrics_environment) + end + + def require_valid_environment_project! + return if project == environment&.project + + errors.add(:environment, "invalid project") + end + + def require_valid_metric_project! + return if prometheus_metric&.common? + return if project == prometheus_metric&.project + + errors.add(:prometheus_metric, "invalid project") + end +end diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index d0dc31476ff..571b586056b 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -2,6 +2,7 @@ class PrometheusMetric < ApplicationRecord belongs_to :project, validate: true, inverse_of: :prometheus_metrics + has_many :prometheus_alerts, inverse_of: :prometheus_metric enum group: PrometheusMetricEnums.groups @@ -73,5 +74,3 @@ class PrometheusMetric < ApplicationRecord PrometheusMetricEnums.group_details.fetch(group.to_sym) end end - -PrometheusMetric.prepend_if_ee('EE::PrometheusMetric') diff --git a/app/models/prometheus_metric_enums.rb b/app/models/prometheus_metric_enums.rb index cdd5e2acfce..75a34618e2c 100644 --- a/app/models/prometheus_metric_enums.rb +++ b/app/models/prometheus_metric_enums.rb @@ -9,7 +9,8 @@ module PrometheusMetricEnums aws_elb: -3, nginx: -4, kubernetes: -5, - nginx_ingress: -6 + nginx_ingress: -6, + cluster_health: -100 }.merge(custom_groups).freeze end @@ -54,6 +55,11 @@ module PrometheusMetricEnums group_title: _('System metrics (Kubernetes)'), required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), priority: 5 + }.freeze, + cluster_health: { + group_title: _('Cluster Health'), + required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), + priority: 10 }.freeze }.merge(custom_group_details).freeze end @@ -76,5 +82,3 @@ module PrometheusMetricEnums }.freeze end end - -PrometheusMetricEnums.prepend_if_ee('EE::PrometheusMetricEnums') diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 735e2bdea81..94c3b83564f 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -2,6 +2,7 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef + include Gitlab::SQL::Pattern scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } @@ -45,6 +46,12 @@ class ProtectedBranch < ApplicationRecord # NOOP # end + + def self.by_name(query) + return none if query.blank? + + where(fuzzy_arel_match(:name, query.downcase)) + end end ProtectedBranch.prepend_if_ee('EE::ProtectedBranch') diff --git a/app/models/release.rb b/app/models/release.rb index ecfae554fe0..2543717895f 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -34,7 +34,6 @@ class Release < ApplicationRecord delegate :repository, to: :project - after_commit :create_evidence!, on: :create, unless: :importing? after_commit :notify_new_release, on: :create, unless: :importing? MAX_NUMBER_TO_DISPLAY = 3 @@ -70,6 +69,10 @@ class Release < ApplicationRecord released_at.present? && released_at > Time.zone.now end + def historical_release? + released_at.present? && released_at < created_at + end + def name self.read_attribute(:name) || tag end @@ -98,10 +101,6 @@ class Release < ApplicationRecord end end - def create_evidence! - CreateEvidenceWorker.perform_async(self.id) - end - def notify_new_release NewReleaseWorker.perform_async(id) end diff --git a/app/models/repository.rb b/app/models/repository.rb index c53b2fc5340..cddffa9bb1d 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -22,7 +22,7 @@ class Repository include Gitlab::RepositoryCacheAdapter - attr_accessor :full_path, :disk_path, :project, :repo_type + attr_accessor :full_path, :disk_path, :container, :repo_type delegate :ref_name_for_sha, to: :raw_repository delegate :bundle_to_disk, to: :raw_repository @@ -41,8 +41,8 @@ class Repository CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count - tag_count avatar exists? root_ref has_visible_content? - issue_template_names merge_request_template_names + tag_count avatar exists? root_ref merged_branch_names + has_visible_content? issue_template_names merge_request_template_names metrics_dashboard_paths xcode_project?).freeze # Methods that use cache_method but only memoize the value @@ -65,10 +65,10 @@ class Repository xcode_config: :xcode_project? }.freeze - def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT) + def initialize(full_path, container, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT) @full_path = full_path @disk_path = disk_path || full_path - @project = project + @container = container @commit_cache = {} @repo_type = repo_type end @@ -95,7 +95,7 @@ class Repository def path_to_repo @path_to_repo ||= begin - storage = Gitlab.config.repositories.storages[project.repository_storage] + storage = Gitlab.config.repositories.storages[container.repository_storage] File.expand_path( File.join(storage.legacy_disk_path, disk_path + '.git') @@ -128,21 +128,12 @@ class Repository commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids) if commits.present? - Commit.decorate(commits, project) + Commit.decorate(commits, container) else [] end end - # the opts are: - # - :path - # - :limit - # - :offset - # - :skip_merges - # - :after - # - :before - # - :all - # - :first_parent def commits(ref = nil, opts = {}) options = { repo: raw_repository, @@ -155,18 +146,19 @@ class Repository after: opts[:after], before: opts[:before], all: !!opts[:all], - first_parent: !!opts[:first_parent] + first_parent: !!opts[:first_parent], + order: opts[:order] } commits = Gitlab::Git::Commit.where(options) - commits = Commit.decorate(commits, project) if commits.present? + commits = Commit.decorate(commits, container) if commits.present? - CommitCollection.new(project, commits, ref) + CommitCollection.new(container, commits, ref) end def commits_between(from, to) commits = Gitlab::Git::Commit.between(raw_repository, from, to) - commits = Commit.decorate(commits, project) if commits.present? + commits = Commit.decorate(commits, container) if commits.present? commits end @@ -174,7 +166,7 @@ class Repository def new_commits(newrev) commits = raw.new_commits(newrev) - ::Commit.decorate(commits, project) + ::Commit.decorate(commits, container) end # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384 @@ -186,7 +178,7 @@ class Repository commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c| commit(c) end - CommitCollection.new(project, commits, ref) + CommitCollection.new(container, commits, ref) end def find_branch(name) @@ -279,7 +271,7 @@ class Repository raw_repository.archive_metadata( ref, storage_path, - project.path, + project&.path, format, append_sha: append_sha, path: path @@ -296,7 +288,7 @@ class Repository end def expire_branches_cache - expire_method_caches(%i(branch_names branch_count has_visible_content?)) + expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content?)) @local_branches = nil @branch_exists_memo = nil end @@ -447,10 +439,8 @@ class Repository def after_import expire_content_cache - # This call is stubbed in tests due to being an expensive operation - # It can be reenabled for specific tests via: - # - # allow(DetectRepositoryLanguagesWorker).to receive(:perform_async).and_call_original + return unless repo_type.project? + DetectRepositoryLanguagesWorker.perform_async(project.id) end @@ -495,7 +485,7 @@ class Repository end def blob_at(sha, path) - blob = Blob.decorate(raw_repository.blob_at(sha, path), project) + blob = Blob.decorate(raw_repository.blob_at(sha, path), container) # Don't attempt to return a special result if there is no blob at all return unless blob @@ -514,10 +504,12 @@ class Repository end # items is an Array like: [[oid, path], [oid1, path1]] - def blobs_at(items) + def blobs_at(items, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) return [] unless exists? - raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) } + raw_repository.batch_blobs(items, blob_size_limit: blob_size_limit).map do |blob| + Blob.decorate(blob, container) + end end def root_ref @@ -695,13 +687,13 @@ class Repository commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit) commits.each do |path, commit| - commits[path] = ::Commit.new(commit, project) + commits[path] = ::Commit.new(commit, container) end end def last_commit_for_path(sha, path) commit = raw_repository.last_commit_for_path(sha, path) - ::Commit.new(commit, project) if commit + ::Commit.new(commit, container) if commit end def last_commit_id_for_path(sha, path) @@ -912,7 +904,29 @@ class Repository @root_ref_sha ||= commit(root_ref).sha end - delegate :merged_branch_names, to: :raw_repository + # If this method is not provided a set of branch names to check merge status, + # it fetches all branches. + def merged_branch_names(branch_names = []) + # Currently we should skip caching if requesting all branch names + # This is only used in a few places, notably app/services/branches/delete_merged_service.rb, + # and it could potentially result in a very large cache/performance issues with the current + # implementation. + skip_cache = branch_names.empty? || Feature.disabled?(:merged_branch_names_redis_caching, default_enabled: true) + return raw_repository.merged_branch_names(branch_names) if skip_cache + + cache = redis_hash_cache + + merged_branch_names_hash = cache.fetch_and_add_missing(:merged_branch_names, branch_names) do |missing_branch_names, hash| + merged = raw_repository.merged_branch_names(missing_branch_names) + + missing_branch_names.each do |bn| + # Redis only stores strings in hset keys, use a fancy encoder + hash[bn] = Gitlab::Redis::Boolean.new(merged.include?(bn)) + end + end + + Set.new(merged_branch_names_hash.select { |_, v| Gitlab::Redis::Boolean.true?(v) }.keys) + end def merge_base(*commits_or_ids) commit_ids = commits_or_ids.map do |commit_or_id| @@ -925,22 +939,12 @@ class Repository def ancestor?(ancestor_id, descendant_id) return false if ancestor_id.nil? || descendant_id.nil? - counter = Gitlab::Metrics.counter( - :repository_ancestor_calls_total, - 'The number of times we call Repository#ancestor with valid arguments') - cache_hit = true - cache_key = "ancestor:#{ancestor_id}:#{descendant_id}" - result = request_store_cache.fetch(cache_key) do + request_store_cache.fetch(cache_key) do cache.fetch(cache_key) do - cache_hit = false raw_repository.ancestor?(ancestor_id, descendant_id) end end - - counter.increment(cache_hit: cache_hit.to_s) - - result end def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) @@ -958,6 +962,7 @@ class Repository # rubocop:disable Gitlab/RailsLogger def async_remove_remote(remote_name) return unless remote_name + return unless project job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name) @@ -988,10 +993,10 @@ class Repository raw_repository.ls_files(actual_ref) end - def search_files_by_content(query, ref) + def search_files_by_content(query, ref, options = {}) return [] if empty? || query.blank? - raw_repository.search_files_by_content(query, ref) + raw_repository.search_files_by_content(query, ref, options) end def search_files_by_name(query, ref) @@ -1044,29 +1049,7 @@ class Repository raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) end - # DEPRECATED: https://gitlab.com/gitlab-org/gitaly/issues/1628 - def rebase_deprecated(user, merge_request) - rebase_sha = raw.rebase_deprecated( - user, - merge_request.id, - branch: merge_request.source_branch, - branch_sha: merge_request.source_branch_sha, - remote_repository: merge_request.target_project.repository.raw, - remote_branch: merge_request.target_branch - ) - - # To support the full deprecated behaviour, set the - # `rebase_commit_sha` for the merge_request here and return the value - merge_request.update(rebase_commit_sha: rebase_sha, merge_error: nil) - - rebase_sha - end - def rebase(user, merge_request, skip_ci: false) - if Feature.disabled?(:two_step_rebase, default_enabled: true) - return rebase_deprecated(user, merge_request) - end - push_options = [] push_options << Gitlab::PushOptions::CI_SKIP if skip_ci @@ -1094,6 +1077,10 @@ class Repository message: message) end + def submodule_links + @submodule_links ||= ::Gitlab::SubmoduleLinks.new(self) + end + def update_submodule(user, submodule, commit_sha, message:, branch:) with_cache_hooks do raw.update_submodule( @@ -1123,12 +1110,26 @@ class Repository true end + def create_from_bundle(bundle_path) + raw.create_from_bundle(bundle_path).tap do |result| + after_create if result + end + end + def blobs_metadata(paths, ref = 'HEAD') references = Array.wrap(paths).map { |path| [ref, path] } Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) } end + def project + if repo_type.snippet? + container.project + else + container + end + end + private # TODO Genericize finder, later split this on finders by Ref or Oid @@ -1140,7 +1141,7 @@ class Repository Gitlab::Git::Commit.find(raw_repository, oid_or_ref) end - ::Commit.new(commit, project) if commit + ::Commit.new(commit, container) if commit end def cache @@ -1151,6 +1152,10 @@ class Repository @redis_set_cache ||= Gitlab::RepositorySetCache.new(self) end + def redis_hash_cache + @redis_hash_cache ||= Gitlab::RepositoryHashCache.new(self) + end + def request_store_cache @request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore) end @@ -1175,10 +1180,10 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(project.repository_storage, + Gitlab::Git::Repository.new(container.repository_storage, disk_path + '.git', - repo_type.identifier_for_subject(project), - project.full_path) + repo_type.identifier_for_container(container), + container.full_path) end end diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb index e60ad6015a5..30f4026e633 100644 --- a/app/models/sentry_issue.rb +++ b/app/models/sentry_issue.rb @@ -5,10 +5,25 @@ class SentryIssue < ApplicationRecord validates :issue, uniqueness: true, presence: true validates :sentry_issue_identifier, presence: true + validate :ensure_sentry_issue_identifier_is_unique_per_project + + after_create_commit :enqueue_sentry_sync_job def self.for_project_and_identifier(project, identifier) joins(:issue) .where(issues: { project_id: project.id }) - .find_by_sentry_issue_identifier(identifier) + .where(sentry_issue_identifier: identifier) + .order('issues.created_at').last + end + + def ensure_sentry_issue_identifier_is_unique_per_project + if issue && self.class.for_project_and_identifier(issue.project, sentry_issue_identifier).present? + # Custom message because field is hidden + errors.add(:_, _('is already associated to a GitLab Issue. New issue will not be associated.')) + end + end + + def enqueue_sentry_sync_job + ErrorTrackingIssueLinkWorker.perform_async(issue.id) end end diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb index a8365649dd1..94d90d3e305 100644 --- a/app/models/serverless/domain_cluster.rb +++ b/app/models/serverless/domain_cluster.rb @@ -4,11 +4,23 @@ module Serverless class DomainCluster < ApplicationRecord self.table_name = 'serverless_domain_cluster' + HEX_REGEXP = %r{\A\h+\z}.freeze + belongs_to :pages_domain belongs_to :knative, class_name: 'Clusters::Applications::Knative', foreign_key: 'clusters_applications_knative_id' belongs_to :creator, class_name: 'User', optional: true - validates :pages_domain, :knative, :uuid, presence: true - validates :uuid, uniqueness: true, length: { is: 14 } + attr_encrypted :key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' + + validates :pages_domain, :knative, presence: true + validates :uuid, presence: true, uniqueness: true, length: { is: Gitlab::Serverless::Domain::UUID_LENGTH }, + format: { with: HEX_REGEXP, message: 'only allows hex characters' } + + default_value_for(:uuid, allows_nil: false) { Gitlab::Serverless::Domain.generate_uuid } + + delegate :domain, to: :pages_domain end end diff --git a/app/models/service.rb b/app/models/service.rb index 95b7c6927cf..e60dda59176 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -260,6 +260,7 @@ class Service < ApplicationRecord def self.available_services_names service_names = %w[ + alerts asana assembla bamboo diff --git a/app/models/snippet.rb b/app/models/snippet.rb index b3b3de21dee..4ba8e6a94e6 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -6,7 +6,6 @@ class Snippet < ApplicationRecord include CacheMarkdownField include Noteable include Participable - include Referable include Sortable include Awardable include Mentionable @@ -15,10 +14,11 @@ class Snippet < ApplicationRecord include Gitlab::SQL::Pattern include FromUnion include IgnorableColumns - + include HasRepository extend ::Gitlab::Utils::Override ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22' + ignore_column :repository_storage, remove_with: '12.10', remove_after: '2020-04-22' cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -41,7 +41,8 @@ class Snippet < ApplicationRecord belongs_to :project has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :user_mentions, class_name: "SnippetUserMention" + has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_one :snippet_repository, inverse_of: :snippet delegate :name, :email, to: :author, prefix: true, allow_nil: true @@ -65,6 +66,8 @@ class Snippet < ApplicationRecord validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } + after_save :store_mentions!, if: :any_mentionable_attributes_changed? + # Scopes scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) } @@ -180,7 +183,7 @@ class Snippet < ApplicationRecord reference = "#{self.class.reference_prefix}#{id}" if project.present? - "#{project.to_reference(from, full: full)}#{reference}" + "#{project.to_reference_base(from, full: full)}#{reference}" else reference end @@ -215,9 +218,7 @@ class Snippet < ApplicationRecord end def embeddable? - ability = project_id? ? :read_project_snippet : :read_personal_snippet - - Ability.allowed?(nil, ability, self) + Ability.allowed?(nil, :read_snippet, self) end def notes_with_associations @@ -229,7 +230,7 @@ class Snippet < ApplicationRecord (public? && (title_changed? || content_changed?)) end - # snippers are the biggest sources of spam + # snippets are the biggest sources of spam override :allow_possible_spam? def allow_possible_spam? false @@ -240,7 +241,7 @@ class Snippet < ApplicationRecord end def to_ability_name - model_name.singular + 'snippet' end def valid_secret_token?(token) @@ -256,6 +257,47 @@ class Snippet < ApplicationRecord super end + def repository + @repository ||= Repository.new(full_path, self, disk_path: disk_path, repo_type: Gitlab::GlRepository::SNIPPET) + end + + def storage + @storage ||= Storage::Hashed.new(self, prefix: Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX) + end + + # This is the full_path used to identify the + # the snippet repository. It will be used mostly + # for logging purposes. + def full_path + return unless persisted? + + @full_path ||= begin + components = [] + components << project.full_path if project_id? + components << '@snippets' + components << self.id + components.join('/') + end + end + + def repository_storage + snippet_repository&.shard_name || + Gitlab::CurrentSettings.pick_repository_storage + end + + def create_repository + return if repository_exists? + + repository.create_if_not_exists + + track_snippet_repository if repository_exists? + end + + def track_snippet_repository + repository = snippet_repository || build_snippet_repository + repository.update!(shard_name: repository_storage, disk_path: disk_path) + end + class << self # Searches for snippets with a matching title or file name. # diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb new file mode 100644 index 00000000000..ba2a061a5f4 --- /dev/null +++ b/app/models/snippet_repository.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SnippetRepository < ApplicationRecord + include Shardable + + belongs_to :snippet, inverse_of: :snippet_repository + + class << self + def find_snippet(disk_path) + find_by(disk_path: disk_path)&.snippet + end + end +end diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 5b9ece8373f..2ec53f58e5f 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -12,4 +12,8 @@ class SpamLog < ApplicationRecord def text [title, description].join("\n") end + + def self.verify_recaptcha!(id:, user_id:) + find_by(id: id, user_id: user_id)&.update!(recaptcha_verified: true) + end end diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed.rb index 9a38b06b2f9..3dea50ab98b 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true module Storage - class HashedProject - attr_accessor :project - delegate :gitlab_shell, :repository_storage, to: :project + class Hashed + attr_accessor :container + delegate :gitlab_shell, :repository_storage, to: :container REPOSITORY_PATH_PREFIX = '@hashed' + SNIPPET_REPOSITORY_PATH_PREFIX = '@snippets' POOL_PATH_PREFIX = '@pools' - def initialize(project, prefix: REPOSITORY_PATH_PREFIX) - @project = project + def initialize(container, prefix: REPOSITORY_PATH_PREFIX) + @container = container @prefix = prefix end @@ -20,9 +21,10 @@ module Storage "#{@prefix}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash end - # Disk path is used to build repository and project's wiki path on disk + # Disk path is used to build repository path on disk # - # @return [String] combination of base_dir and the repository own name without `.git` or `.wiki.git` extensions + # @return [String] combination of base_dir and the repository own name + # without `.git`, `.wiki.git`, or any other extension def disk_path "#{base_dir}/#{disk_hash}" if disk_hash end @@ -33,10 +35,10 @@ module Storage private - # Generates the hash for the project path and name on disk + # Generates the hash for the repository path and name on disk # If you need to refer to the repository on disk, use the `#disk_path` def disk_hash - @disk_hash ||= Digest::SHA2.hexdigest(project.id.to_s) if project.id + @disk_hash ||= Digest::SHA2.hexdigest(container.id.to_s) if container.id end end end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 5a44ee7211b..6324636db1e 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -17,7 +17,7 @@ class SystemNoteMetadata < ApplicationRecord commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged duplicate locked unlocked - outdated tag due_date pinned_embed + outdated tag due_date pinned_embed cherry_pick ].freeze validates :note, presence: true diff --git a/app/models/todo.rb b/app/models/todo.rb index f217c942e8e..d337ef33051 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -51,10 +51,12 @@ class Todo < ApplicationRecord validates :project, presence: true, unless: :group_id validates :group, presence: true, unless: :project_id + scope :for_ids, -> (ids) { where(id: ids) } scope :pending, -> { with_state(:pending) } scope :done, -> { with_state(:done) } scope :for_action, -> (action) { where(action: action) } scope :for_author, -> (author) { where(author: author) } + scope :for_user, -> (user) { where(user: user) } scope :for_project, -> (projects) { where(project: projects) } scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) } scope :for_group, -> (group) { where(group: group) } diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb index 29f376670da..442ed733566 100644 --- a/app/models/uploads/base.rb +++ b/app/models/uploads/base.rb @@ -7,7 +7,7 @@ module Uploads attr_reader :logger def initialize(logger: nil) - @logger ||= Rails.logger # rubocop:disable Gitlab/RailsLogger + @logger = Rails.logger # rubocop:disable Gitlab/RailsLogger end def delete_keys_async(keys_to_delete) diff --git a/app/models/user.rb b/app/models/user.rb index df6c28f5076..ec9bc7ae01e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,8 @@ class User < ApplicationRecord MINIMUM_INACTIVE_DAYS = 180 + enum bot_type: ::UserBotTypeEnums.bots + # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour # rubocop: disable CodeReuse/ServiceClass @@ -101,6 +103,7 @@ class User < ApplicationRecord # Groups has_many :members + has_one :max_access_level_membership, -> { select(:id, :user_id, :access_level).order(access_level: :desc).readonly }, class_name: 'Member' has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember' has_many :groups, through: :group_members has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group @@ -187,6 +190,12 @@ class User < ApplicationRecord validate :owns_commit_email, if: :commit_email_changed? validate :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id } + validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, + message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } + + validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids, + message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } + before_validation :sanitize_attrs before_validation :set_notification_email, if: :new_record? before_validation :set_public_email, if: :public_email_changed? @@ -223,19 +232,19 @@ class User < ApplicationRecord after_initialize :set_projects_limit # User's Layout preference - enum layout: [:fixed, :fluid] + enum layout: { fixed: 0, fluid: 1 } # User's Dashboard preference # Note: When adding an option, it MUST go on the end of the array. - enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos, :issues, :merge_requests, :operations] + enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 } # User's Project preference # Note: When adding an option, it MUST go on the end of the array. - enum project_view: [:readme, :activity, :files] + enum project_view: { readme: 0, activity: 1, files: 2 } # User's role # Note: When adding an option, it MUST go on the end of the array. - enum role: [:software_developer, :development_team_lead, :devops_engineer, :systems_administrator, :security_analyst, :data_analyst, :product_manager, :product_designer, :other], _suffix: true + enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :notes_filter_for, to: :user_preference @@ -245,6 +254,7 @@ class User < ApplicationRecord delegate :time_display_relative, :time_display_relative=, to: :user_preference delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference + delegate :tab_width, :tab_width=, to: :user_preference delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference delegate :setup_for_company, :setup_for_company=, to: :user_preference delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference @@ -321,6 +331,8 @@ class User < ApplicationRecord scope :with_emails, -> { preload(:emails) } scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } + scope :bots, -> { where.not(bot_type: nil) } + scope :humans, -> { where(bot_type: nil) } scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do where('EXISTS (?)', @@ -597,6 +609,15 @@ class User < ApplicationRecord end end + def alert_bot + email_pattern = "alert%s@#{Settings.gitlab.host}" + + unique_internal(where(bot_type: :alert_bot), 'alert-bot', email_pattern) do |u| + u.bio = 'The GitLab alert bot' + u.name = 'GitLab Alert Bot' + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -612,16 +633,20 @@ class User < ApplicationRecord username end + def bot? + bot_type.present? + end + def internal? - ghost? + ghost? || bot? end def self.internal - where(ghost: true) + where(ghost: true).or(bots) end def self.non_internal - without_ghosts + without_ghosts.humans end # @@ -1027,7 +1052,7 @@ class User < ApplicationRecord end def highest_role - members.maximum(:access_level) || Gitlab::Access::NO_ACCESS + max_access_level_membership&.access_level || Gitlab::Access::NO_ACCESS end def accessible_deploy_keys @@ -1201,7 +1226,8 @@ class User < ApplicationRecord { name: name, username: username, - avatar_url: avatar_url(only_path: false) + avatar_url: avatar_url(only_path: false), + email: email } end @@ -1526,6 +1552,13 @@ class User < ApplicationRecord end def read_only_attribute?(attribute) + if Feature.enabled?(:ldap_readonly_attributes, default_enabled: true) + enabled = Gitlab::Auth::LDAP::Config.enabled? + read_only = attribute.to_sym.in?(UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES) + + return true if enabled && read_only + end + user_synced_attributes_metadata&.read_only?(attribute) end @@ -1618,6 +1651,13 @@ class User < ApplicationRecord end # End of signup_flow experiment methods + def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil) + callouts = self.callouts.with_feature_name(feature_name) + callouts = callouts.with_dismissed_after(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than + + callouts.any? + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups @@ -1630,13 +1670,6 @@ class User < ApplicationRecord super end - # override from Devise::Confirmable - def confirmation_period_valid? - return false if Feature.disabled?(:soft_email_confirmation) - - super - end - private def default_private_profile_to_false diff --git a/app/models/user_bot_type_enums.rb b/app/models/user_bot_type_enums.rb new file mode 100644 index 00000000000..b6b08ce650b --- /dev/null +++ b/app/models/user_bot_type_enums.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module UserBotTypeEnums + def self.bots + # When adding a new key, please ensure you are not conflicting with EE-only keys in app/models/user_bot_types_enums.rb + { + alert_bot: 2 + } + end +end + +UserBotTypeEnums.prepend_if_ee('EE::UserBotTypeEnums') diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 027ee44c6a9..82f82356cb4 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -12,4 +12,7 @@ class UserCallout < ApplicationRecord presence: true, uniqueness: { scope: :user_id }, inclusion: { in: UserCallout.feature_names.keys } + + scope :with_feature_name, -> (feature_name) { where(feature_name: UserCallout.feature_names[feature_name]) } + scope :with_dismissed_after, -> (dismissed_after) { where('dismissed_at > ?', dismissed_after) } end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 713b0598029..48a56cded0e 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -9,7 +9,13 @@ class UserPreference < ApplicationRecord belongs_to :user validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true + validates :tab_width, numericality: { + only_integer: true, + greater_than_or_equal_to: Gitlab::TabWidth::MIN, + less_than_or_equal_to: Gitlab::TabWidth::MAX + } + default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index c6867e48cbf..26beb77a025 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -5,6 +5,9 @@ class WikiPage PageChangedError = Class.new(StandardError) PageRenameError = Class.new(StandardError) + MAX_TITLE_BYTES = 245 + MAX_DIRECTORY_BYTES = 255 + include ActiveModel::Validations include ActiveModel::Conversion include StaticModel @@ -51,6 +54,7 @@ class WikiPage validates :title, presence: true validates :content, presence: true + validate :validate_path_limits, if: :title_changed? # The GitLab ProjectWiki instance. attr_reader :wiki @@ -262,7 +266,7 @@ class WikiPage end def title_changed? - title.present? && self.class.unhyphenize(@page.url_path) != title + title.present? && (@page.nil? || self.class.unhyphenize(@page.url_path) != title) end # Updates the current @attributes hash by merging a hash of params @@ -324,4 +328,16 @@ class WikiPage set_attributes @persisted = errors.blank? end + + def validate_path_limits + *dirnames, title = @attributes[:title].split('/') + + if title.bytesize > MAX_TITLE_BYTES + errors.add(:title, _("exceeds the limit of %{bytes} bytes for page titles") % { bytes: MAX_TITLE_BYTES }) + end + + if dirnames.any? { |d| d.bytesize > MAX_DIRECTORY_BYTES } + errors.add(:title, _("exceeds the limit of %{bytes} bytes for directory names") % { bytes: MAX_DIRECTORY_BYTES }) + end + end end diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb new file mode 100644 index 00000000000..43927e65db1 --- /dev/null +++ b/app/models/x509_certificate.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class X509Certificate < ApplicationRecord + include X509SerialNumberAttribute + + x509_serial_number_attribute :serial_number + + enum certificate_status: { + good: 0, + revoked: 1 + } + + belongs_to :x509_issuer, class_name: 'X509Issuer', foreign_key: 'x509_issuer_id', optional: false + + has_many :x509_commit_signatures, inverse_of: 'x509_certificate' + + # rfc 5280 - 4.2.1.2 Subject Key Identifier + validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + # rfc 5280 - 4.1.2.6 Subject + validates :subject, presence: true + # rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address) + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + # rfc 5280 - 4.1.2.2 Serial number + validates :serial_number, presence: true, numericality: { only_integer: true } + + validates :x509_issuer_id, presence: true + + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier]) + end +end diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb new file mode 100644 index 00000000000..ed7c638cecc --- /dev/null +++ b/app/models/x509_commit_signature.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class X509CommitSignature < ApplicationRecord + include ShaAttribute + + sha_attribute :commit_sha + + enum verification_status: { + unverified: 0, + verified: 1 + } + + belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false + belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false + + validates :commit_sha, presence: true + validates :project_id, presence: true + validates :x509_certificate_id, presence: true + + scope :by_commit_sha, ->(shas) { where(commit_sha: shas) } + + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) + end + + # Find commits that are lacking a signature in the database at present + def self.unsigned_commit_shas(commit_shas) + return [] if commit_shas.empty? + + signed = by_commit_sha(commit_shas).pluck(:commit_sha) + commit_shas - signed + end + + def commit + project.commit(commit_sha) + end + + def x509_commit + return unless commit + + Gitlab::X509::Commit.new(commit) + end +end diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb new file mode 100644 index 00000000000..514b38808ef --- /dev/null +++ b/app/models/x509_issuer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class X509Issuer < ApplicationRecord + has_many :x509_certificates, inverse_of: 'x509_issuer' + + # rfc 5280 - 4.2.1.1 Authority Key Identifier + validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + # rfc 5280 - 4.1.2.4 Issuer + validates :subject, presence: true + # rfc 5280 - 4.2.1.14 CRL Distribution Points + # cRLDistributionPoints extension using URI:http + validates :crl_url, presence: true, public_url: true + + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier]) + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index c93a19bdc3d..ce3e5b0195c 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -44,6 +44,9 @@ class BasePolicy < DeclarativePolicy::Base ::Gitlab::ExternalAuthorization.perform_check? end + with_options scope: :user, score: 0 + condition(:alert_bot) { @user&.alert_bot? } + rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do prevent :read_cross_project end diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index b963a64b429..406677d7b56 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -33,6 +33,10 @@ module PolicyActor def can_create_group false end + + def alert_bot? + false + end end PolicyActor.prepend_if_ee('EE::PolicyActor') diff --git a/app/policies/error_tracking/detailed_error_policy.rb b/app/policies/error_tracking/base_policy.rb index cb74242d46a..ea56106ed89 100644 --- a/app/policies/error_tracking/detailed_error_policy.rb +++ b/app/policies/error_tracking/base_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ErrorTracking - class DetailedErrorPolicy < BasePolicy + class BasePolicy < ::BasePolicy delegate { @subject.gitlab_project } end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 2187c703760..2bde7bcca08 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -82,7 +82,7 @@ class GlobalPolicy < BasePolicy rule { ~anonymous }.policy do enable :read_instance_metadata - enable :create_personal_snippet + enable :create_snippet end rule { admin }.policy do @@ -90,7 +90,7 @@ class GlobalPolicy < BasePolicy enable :update_custom_attribute end - rule { external_user }.prevent :create_personal_snippet + rule { external_user }.prevent :create_snippet end GlobalPolicy.prepend_if_ee('EE::GlobalPolicy') diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 1cd400e4dfa..3bb7ab05be2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -67,6 +67,7 @@ class GroupPolicy < BasePolicy enable :read_milestone enable :read_list enable :read_label + enable :read_board end rule { has_access }.enable :read_namespace diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index c2fcf1a1010..bc60913563c 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -6,19 +6,19 @@ class PersonalSnippetPolicy < BasePolicy condition(:internal_snippet, scope: :subject) { @subject.internal? } rule { public_snippet }.policy do - enable :read_personal_snippet + enable :read_snippet enable :create_note end rule { is_author | admin }.policy do - enable :read_personal_snippet - enable :update_personal_snippet - enable :admin_personal_snippet + enable :read_snippet + enable :update_snippet + enable :admin_snippet enable :create_note end rule { internal_snippet & ~external_user }.policy do - enable :read_personal_snippet + enable :read_snippet enable :create_note end @@ -26,8 +26,5 @@ class PersonalSnippetPolicy < BasePolicy rule { can?(:create_note) }.enable :award_emoji - rule { can?(:read_all_resources) }.enable :read_personal_snippet - - # Aliasing the ability to ease GraphQL permissions check - rule { can?(:read_personal_snippet) }.enable :read_snippet + rule { can?(:read_all_resources) }.enable :read_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e38eef527be..507e227c952 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -9,7 +9,7 @@ class ProjectPolicy < BasePolicy merge_request label milestone - project_snippet + snippet wiki note pipeline @@ -185,7 +185,7 @@ class ProjectPolicy < BasePolicy enable :read_issue enable :read_label enable :read_milestone - enable :read_project_snippet + enable :read_snippet enable :read_project_member enable :read_note enable :create_project @@ -208,7 +208,7 @@ class ProjectPolicy < BasePolicy enable :download_code enable :read_statistics enable :download_wiki_code - enable :create_project_snippet + enable :create_snippet enable :update_issue enable :reopen_issue enable :admin_issue @@ -222,6 +222,7 @@ class ProjectPolicy < BasePolicy enable :read_deployment enable :read_merge_request enable :read_sentry_issue + enable :update_sentry_issue enable :read_prometheus end @@ -285,8 +286,8 @@ class ProjectPolicy < BasePolicy rule { can?(:maintainer_access) }.policy do enable :admin_board enable :push_to_delete_protected_branch - enable :update_project_snippet - enable :admin_project_snippet + enable :update_snippet + enable :admin_snippet enable :admin_project_member enable :admin_note enable :admin_wiki @@ -351,7 +352,7 @@ class ProjectPolicy < BasePolicy end rule { snippets_disabled }.policy do - prevent(*create_read_update_admin_destroy(:project_snippet)) + prevent(*create_read_update_admin_destroy(:snippet)) end rule { wiki_disabled }.policy do @@ -369,7 +370,7 @@ class ProjectPolicy < BasePolicy # There's two separate cases when builds_disabled is true: # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled - # - We do not prevent the user from accessing Pipelines to allow him to access external CI + # - We do not prevent the user from accessing Pipelines to allow them to access external CI # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled # - We prevent the user from accessing Pipelines rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do @@ -404,7 +405,7 @@ class ProjectPolicy < BasePolicy enable :read_wiki enable :read_label enable :read_milestone - enable :read_project_snippet + enable :read_snippet enable :read_project_member enable :read_merge_request enable :read_note @@ -514,6 +515,8 @@ class ProjectPolicy < BasePolicy end def lookup_access_level! + return ::Gitlab::Access::REPORTER if alert_bot? + # NOTE: max_member_access has its own cache project.team.max_member_access(@user.id) end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index a9094fbd958..a38d9154102 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -14,44 +14,41 @@ class ProjectSnippetPolicy < BasePolicy # We have to check both project feature visibility and a snippet visibility and take the stricter one # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-foss/issues/27573 rule { ~can?(:read_project) }.policy do - prevent :read_project_snippet - prevent :update_project_snippet - prevent :admin_project_snippet + prevent :read_snippet + prevent :update_snippet + prevent :admin_snippet end - # we have to use this complicated prevent because the delegated project policy - # is overly greedy in allowing :read_project_snippet, since it doesn't have any - # information about the snippet. However, :read_project_snippet on the *project* - # is used to hide/show various snippet-related controls, so we can't just move - # all of the handling here. + # we have to use this complicated prevent because the delegated project + # policy is overly greedy in allowing :read_snippet, since it doesn't have + # any information about the snippet. However, :read_snippet on the *project* + # is used to hide/show various snippet-related controls, so we can't just + # move all of the handling here. rule do all?(private_snippet | (internal_snippet & external_user), ~project.guest, ~is_author, ~can?(:read_all_resources)) - end.prevent :read_project_snippet + end.prevent :read_snippet rule { internal_snippet & ~is_author & ~admin }.policy do - prevent :update_project_snippet - prevent :admin_project_snippet + prevent :update_snippet + prevent :admin_snippet end - rule { public_snippet }.enable :read_project_snippet + rule { public_snippet }.enable :read_snippet rule { is_author & ~project.reporter & ~admin }.policy do - prevent :admin_project_snippet + prevent :admin_snippet end rule { is_author | admin }.policy do - enable :read_project_snippet - enable :update_project_snippet - enable :admin_project_snippet + enable :read_snippet + enable :update_snippet + enable :admin_snippet end - rule { ~can?(:read_project_snippet) }.prevent :create_note - - # Aliasing the ability to ease GraphQL permissions check - rule { can?(:read_project_snippet) }.enable :read_snippet + rule { ~can?(:read_snippet) }.prevent :create_note end ProjectSnippetPolicy.prepend_if_ee('EE::ProjectSnippetPolicy') diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 3a71d2b87f3..e0077db8d5c 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -9,7 +9,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated Gitlab::Highlight.highlight( blob.path, limited_blob_data(to: to), - language: blob.language_from_gitattributes, + language: language, plain: plain ) end @@ -37,4 +37,8 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated def all_lines @all_lines ||= blob.data.lines end + + def language + blob.language_from_gitattributes + end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index f01ff56540a..37abefb5664 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -70,18 +70,22 @@ module Ci end end - def all_related_merge_request_text + def all_related_merge_request_text(limit: nil) if all_related_merge_requests.none? - 'No related merge requests found.' + _("No related merge requests found.") else _("%{count} related %{pluralized_subject}: %{links}" % { count: all_related_merge_requests.count, - pluralized_subject: 'merge request'.pluralize(all_related_merge_requests.count), - links: all_related_merge_request_links.join(', ') + pluralized_subject: n_('merge request', 'merge requests', all_related_merge_requests.count), + links: all_related_merge_request_links(limit: limit).join(', ') }).html_safe end end + def has_many_merge_requests? + all_related_merge_requests.count > 1 + end + def link_to_pipeline_ref link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), @@ -112,14 +116,16 @@ module Ci def merge_request_presenter strong_memoize(:merge_request_presenter) do - if pipeline.triggered_by_merge_request? + if pipeline.merge_request? pipeline.merge_request.present(current_user: current_user) end end end - def all_related_merge_request_links - all_related_merge_requests.map do |merge_request| + def all_related_merge_request_links(limit: nil) + limit ||= all_related_merge_requests.count + + all_related_merge_requests.first(limit).map do |merge_request| mr_path = project_merge_request_path(merge_request.project, merge_request) link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 66ae840a619..258852c77c6 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -13,7 +13,13 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated archived_failure: 'The job is archived and cannot be run', unmet_prerequisites: 'The job failed to complete prerequisite tasks', scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator', - data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator' + data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator', + forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run', + invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid', + downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found', + insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline', + bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines', + downstream_pipeline_creation_failed: 'The downstream pipeline could not be created' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES diff --git a/app/presenters/milestone_presenter.rb b/app/presenters/milestone_presenter.rb new file mode 100644 index 00000000000..7d9045ddebe --- /dev/null +++ b/app/presenters/milestone_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class MilestonePresenter < Gitlab::View::Presenter::Delegated + presents :milestone + + def milestone_path + url_builder.milestone_path(milestone) + end + + private + + def url_builder + @url_builder ||= Gitlab::UrlBuilder.new(milestone) + end +end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 8c24d07675a..3af6be26843 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -208,7 +208,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated AnchorData.new(false, statistic_icon + _('New file'), project_new_blob_path(project, default_branch || 'master'), - 'success') + 'missing') end end @@ -302,7 +302,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) AnchorData.new(false, - _('Kubernetes configured'), + _('Kubernetes'), cluster_link, 'default') end diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb new file mode 100644 index 00000000000..8988c567c5c --- /dev/null +++ b/app/presenters/projects/prometheus/alert_presenter.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Projects + module Prometheus + class AlertPresenter < Gitlab::View::Presenter::Delegated + RESERVED_ANNOTATIONS = %w(gitlab_incident_markdown title).freeze + GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze + MARKDOWN_LINE_BREAK = " \n".freeze + + def full_title + [environment_name, alert_title].compact.join(': ') + end + + def project_full_path + project.full_path + end + + def metric_query + gitlab_alert&.full_query + end + + def environment_name + environment&.name + end + + def performance_dashboard_link + if environment + metrics_project_environment_url(project, environment) + else + metrics_project_environments_url(project) + end + end + + def starts_at + super&.rfc3339 + end + + def issue_summary_markdown + <<~MARKDOWN.chomp + #### Summary + + #{metadata_list} + #{alert_details} + MARKDOWN + end + + private + + def alert_title + query_title || title + end + + def query_title + return unless gitlab_alert + + "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes" + end + + def metadata_list + metadata = [] + + metadata << list_item('Start time', starts_at) if starts_at + metadata << list_item('full_query', backtick(full_query)) if full_query + metadata << list_item(service.label.humanize, service.value) if service + metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool + metadata << list_item(hosts.label.humanize, host_links) if hosts + + metadata.join(MARKDOWN_LINE_BREAK) + end + + def alert_details + if annotation_list.present? + <<~MARKDOWN.chomp + + #### Alert Details + + #{annotation_list} + MARKDOWN + end + end + + def annotation_list + strong_memoize(:annotation_list) do + annotations + .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) } + .map { |annotation| list_item(annotation.label, annotation.value) } + .join(MARKDOWN_LINE_BREAK) + end + end + + def list_item(key, value) + "**#{key}:** #{value}".strip + end + + def backtick(value) + "`#{value}`" + end + + GENERIC_ALERT_SUMMARY_ANNOTATIONS.each do |annotation_name| + define_method(annotation_name) do + annotations.find { |a| a.label == annotation_name } + end + end + + def host_links + Array(hosts.value).join(' ') + end + end + end +end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index 099ac9b09cd..2f91495c34c 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -19,6 +19,12 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated project_tag_path(project, release.tag) end + def self_url + return unless ::Feature.enabled?(:release_show_page, project) + + project_release_url(project, release) + end + def merge_requests_url return unless release_mr_issue_urls_available? diff --git a/app/presenters/sentry_detailed_error_presenter.rb b/app/presenters/sentry_error_presenter.rb index 9329f987879..ba724b0f8be 100644 --- a/app/presenters/sentry_detailed_error_presenter.rb +++ b/app/presenters/sentry_error_presenter.rb @@ -1,10 +1,22 @@ # frozen_string_literal: true -class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated +class SentryErrorPresenter < Gitlab::View::Presenter::Delegated presents :error FrequencyStruct = Struct.new(:time, :count, keyword_init: true) + def first_seen + DateTime.parse(error.first_seen) + end + + def last_seen + DateTime.parse(error.last_seen) + end + + def project_id + Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s + end + def frequency utc_offset = Time.zone_offset('UTC') diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb new file mode 100644 index 00000000000..70a373619d6 --- /dev/null +++ b/app/presenters/snippet_blob_presenter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class SnippetBlobPresenter < BlobPresenter + def rich_data + return if blob.binary? + + if markup? + blob.rendered_markup + else + highlight(plain: false) + end + end + + def plain_data + return if blob.binary? + + highlight(plain: !markup?) + end + + def raw_path + if snippet.is_a?(ProjectSnippet) + raw_project_snippet_path(snippet.project, snippet) + else + raw_snippet_path(snippet) + end + end + + private + + def markup? + blob.rich_viewer&.partial_name == 'markup' + end + + def snippet + blob.snippet + end + + def language + nil + end +end diff --git a/app/serializers/README.md b/app/serializers/README.md index 93b21786015..2cbe6f9d263 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -64,7 +64,7 @@ A new serializer should inherit from a `BaseSerializer` class. It is necessary to specify which serialization entity will be used to serialize a resource. ```ruby -class MyResourceSerializer < BaseSerialize +class MyResourceSerializer < BaseSerializer entity MyResourceEntity end ``` diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index d1750695523..fac0fbd14b9 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -15,7 +15,7 @@ class BuildArtifactEntity < Grape::Entity fast_download_project_job_artifacts_path(project, job) end - expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job| + expose :keep_path, if: -> (*) { job.has_expiring_archive_artifacts? } do |job| fast_keep_project_job_artifacts_path(project, job) end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 480a8cab6ff..fe6afa4ff6f 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -22,6 +22,12 @@ class BuildDetailsEntity < JobEntity end end + expose :deployment_cluster, if: -> (build) { build&.deployment&.cluster } do |build, options| + # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster + # https://gitlab.com/gitlab-org/gitlab/issues/202628 + DeploymentClusterEntity.represent(build.deployment, options) + end + expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do expose :download_path, if: -> (*) { build.artifacts? } do |build| download_project_job_artifacts_path(project, build) @@ -31,7 +37,7 @@ class BuildDetailsEntity < JobEntity browse_project_job_artifacts_path(project, build) end - expose :keep_path, if: -> (*) { build.has_expiring_artifacts? && can?(current_user, :update_build, build) } do |build| + expose :keep_path, if: -> (*) { build.has_expiring_archive_artifacts? && can?(current_user, :update_build, build) } do |build| keep_project_job_artifacts_path(project, build) end diff --git a/app/serializers/cluster_basic_entity.rb b/app/serializers/cluster_basic_entity.rb deleted file mode 100644 index d104f2c8bbd..00000000000 --- a/app/serializers/cluster_basic_entity.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class ClusterBasicEntity < Grape::Entity - include RequestAwareEntity - - expose :name - expose :path, if: -> (cluster) { can?(request.current_user, :read_cluster, cluster) } do |cluster| - cluster.present(current_user: request.current_user).show_path - end -end diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index a81e377691e..633b117d392 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -3,7 +3,7 @@ module UserStatusTooltip extend ActiveSupport::Concern include ActionView::Helpers::TagHelper - include ::Gitlab::ActionViewOutput::Context + include ActionView::Context include EmojiHelper include UsersHelper diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb index bc35a67ff24..0e9bdee187b 100644 --- a/app/serializers/container_repositories_serializer.rb +++ b/app/serializers/container_repositories_serializer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ContainerRepositoriesSerializer < BaseSerializer + include WithPagination entity ContainerRepositoryEntity def represent_read_only(resource) diff --git a/app/serializers/deployment_cluster_entity.rb b/app/serializers/deployment_cluster_entity.rb new file mode 100644 index 00000000000..98736472b62 --- /dev/null +++ b/app/serializers/deployment_cluster_entity.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class DeploymentClusterEntity < Grape::Entity + include RequestAwareEntity + + # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster + # https://gitlab.com/gitlab-org/gitlab/issues/202628 + + expose :name do |deployment| + deployment.cluster.name + end + + expose :path, if: -> (deployment) { can?(request.current_user, :read_cluster, deployment.cluster) } do |deployment| + deployment.cluster.present(current_user: request.current_user).show_path + end + + expose :kubernetes_namespace, if: -> (deployment) { can?(request.current_user, :read_cluster, deployment.cluster) } do |deployment| + deployment.kubernetes_namespace + end +end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 94773eeebd0..dc7c4654208 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -41,7 +41,11 @@ class DeploymentEntity < Grape::Entity JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) end - expose :cluster, using: ClusterBasicEntity + expose :cluster do |deployment, options| + # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster + # https://gitlab.com/gitlab-org/gitlab/issues/202628 + DeploymentClusterEntity.represent(deployment, options) unless deployment.cluster.nil? + end private diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 88e09ae8c0b..02f78180fb0 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -24,6 +24,10 @@ class DiffsEntity < Grape::Entity ) end + expose :context_commits, using: API::Entities::Commit, if: -> (diffs, options) { merge_request&.project&.context_commits_enabled? } do |diffs| + options[:context_commits] + end + expose :merge_request_diff, using: MergeRequestDiffEntity do |diffs| options[:merge_request_diff] end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb index 5c79b165ee9..aa0ac7d2a7e 100644 --- a/app/serializers/merge_request_diff_entity.rb +++ b/app/serializers/merge_request_diff_entity.rb @@ -34,6 +34,14 @@ class MergeRequestDiffEntity < Grape::Entity merge_request_version_path(project, merge_request, merge_request_diff) end + expose :head_version_path do |merge_request_diff| + project = merge_request.target_project + + next unless project && merge_request.diffable_merge_ref? + + diffs_project_merge_request_path(project, merge_request, diff_head: true) + end + expose :version_path do |merge_request_diff| start_sha = options[:start_sha] project = merge_request.target_project diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 2a81931c49f..7d67a35c94c 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -50,6 +50,19 @@ class MergeRequestWidgetEntity < Grape::Entity ci_environments_status_project_merge_request_path(merge_request.project, merge_request) end + expose :merge_request_add_ci_config_path, if: ->(mr, _) { can_add_ci_config_path?(mr) } do |merge_request| + project_new_blob_path( + merge_request.source_project, + merge_request.source_branch, + file_name: '.gitlab-ci.yml', + commit_message: s_("CommitMessage|Add %{file_name}") % { file_name: Gitlab::FileDetector::PATTERNS[:gitlab_ci] } + ) + end + + expose :human_access do |merge_request| + merge_request.project.team.human_max_access(current_user&.id) + end + # Rendering and redacting Markdown can be expensive. These links are # just nice to have in the merge request widget, so only # include them if they are explicitly requested on first load. @@ -67,14 +80,6 @@ class MergeRequestWidgetEntity < Grape::Entity end end - def as_json(options = {}) - return super(options) if Feature.enabled?(:async_mr_widget) - - super(options) - .merge(MergeRequestPollCachedWidgetEntity.new(object, **@options.opts_hash).as_json(options)) - .merge(MergeRequestPollWidgetEntity.new(object, **@options.opts_hash).as_json(options)) - end - private delegate :current_user, to: :request @@ -83,6 +88,13 @@ class MergeRequestWidgetEntity < Grape::Entity @presenters ||= {} @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter end + + def can_add_ci_config_path?(merge_request) + merge_request.source_project&.uses_default_ci_config? && + merge_request.all_pipelines.none? && + merge_request.commits_count.positive? && + can?(current_user, :push_code, merge_request.source_project) + end end MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity') diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index a4ab1d399bc..a58278cf4ef 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -8,7 +8,12 @@ class PipelineDetailsEntity < PipelineEntity end expose :details do - expose :artifacts, using: BuildArtifactEntity + expose :artifacts do |pipeline, options| + rel = pipeline.artifacts + rel = rel.eager_load_job_artifacts_archive if options.fetch(:preload_job_artifacts_archive, true) + + BuildArtifactEntity.represent(rel, options) + end expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index ba8f4fffe02..c3ddbb88c9c 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -25,7 +25,7 @@ class PipelineEntity < Grape::Entity expose :flags do expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops - expose :merge_request_event?, as: :merge_request + expose :merge_request?, as: :merge_request expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable @@ -59,11 +59,11 @@ class PipelineEntity < Grape::Entity expose :tag?, as: :tag expose :branch?, as: :branch - expose :merge_request_event?, as: :merge_request + expose :merge_request?, as: :merge_request end expose :commit, using: CommitEntity - expose :merge_request_event_type, if: -> (pipeline, _) { pipeline.merge_request_event? } + expose :merge_request_event_type, if: -> (pipeline, _) { pipeline.merge_request? } expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? } expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? } expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } @@ -104,7 +104,7 @@ class PipelineEntity < Grape::Entity end def has_presentable_merge_request? - pipeline.triggered_by_merge_request? && + pipeline.merge_request? && can?(request.current_user, :read_merge_request, pipeline.merge_request) end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index be535a5d414..3ad9f2bc0bf 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -7,6 +7,10 @@ class PipelineSerializer < BaseSerializer # rubocop: disable CodeReuse/ActiveRecord def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) + # We don't want PipelineDetailsEntity to preload the job_artifacts_archive + # because we do it with preloaded_relations in a more optimal way + # if the given resource is a collection of multiple pipelines. + opts[:preload_job_artifacts_archive] = false resource = resource.preload(preloaded_relations) end @@ -58,7 +62,8 @@ class PipelineSerializer < BaseSerializer pending_builds: :project, project: [:route, { namespace: :route }], artifacts: { - project: [:route, { namespace: :route }] + project: [:route, { namespace: :route }], + job_artifacts_archive: [] } }, { triggered_by_pipeline: [:project, :user] }, diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index 10360e575bb..05beb562e40 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -5,91 +5,31 @@ module Projects class ServiceEntity < Grape::Entity include RequestAwareEntity - expose :name do |service| - service.dig('metadata', 'name') - end - - expose :namespace do |service| - service.dig('metadata', 'namespace') - end - - expose :environment_scope do |service| - service.dig('environment_scope') - end - - expose :cluster_id do |service| - service.dig('cluster_id') - end + expose :name + expose :namespace + expose :environment_scope + expose :podcount + expose :created_at + expose :image + expose :description + expose :url expose :detail_url do |service| project_serverless_path( request.project, - service.dig('environment_scope'), - service.dig('metadata', 'name')) - end - - expose :podcount do |service| - service.dig('podcount') + service.environment_scope, + service.name) end expose :metrics_url do |service| project_serverless_metrics_path( request.project, - service.dig('environment_scope'), - service.dig('metadata', 'name')) + ".json" - end - - expose :created_at do |service| - service.dig('metadata', 'creationTimestamp') - end - - expose :url do |service| - knative_06_07_url(service) || knative_05_url(service) - end - - expose :description do |service| - knative_07_description(service) || knative_05_06_description(service) + service.environment_scope, + service.name, format: :json) end - expose :image do |service| - service.dig( - 'spec', - 'runLatest', - 'configuration', - 'build', - 'template', - 'name') - end - - private - - def knative_07_description(service) - service.dig( - 'spec', - 'template', - 'metadata', - 'annotations', - 'Description' - ) - end - - def knative_05_url(service) - "http://#{service.dig('status', 'domain')}" - end - - def knative_06_07_url(service) - service.dig('status', 'url') - end - - def knative_05_06_description(service) - service.dig( - 'spec', - 'runLatest', - 'configuration', - 'revisionTemplate', - 'metadata', - 'annotations', - 'Description') + expose :cluster_id do |service| + service.cluster&.id end end end diff --git a/app/serializers/test_reports_comparer_entity.rb b/app/serializers/test_reports_comparer_entity.rb index d7a3dd34fdc..5f8a68338cc 100644 --- a/app/serializers/test_reports_comparer_entity.rb +++ b/app/serializers/test_reports_comparer_entity.rb @@ -7,6 +7,7 @@ class TestReportsComparerEntity < Grape::Entity expose :total_count, as: :total expose :resolved_count, as: :resolved expose :failed_count, as: :failed + expose :error_count, as: :errored end expose :suite_comparers, as: :suites, using: TestSuiteComparerEntity diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb index d402a4d5718..78c243f75b8 100644 --- a/app/serializers/test_suite_comparer_entity.rb +++ b/app/serializers/test_suite_comparer_entity.rb @@ -11,6 +11,7 @@ class TestSuiteComparerEntity < Grape::Entity expose :total_count, as: :total expose :resolved_count, as: :resolved expose :failed_count, as: :failed + expose :error_count, as: :errored end # rubocop: disable CodeReuse/ActiveRecord @@ -28,6 +29,20 @@ class TestSuiteComparerEntity < Grape::Entity max_tests(suite.new_failures, suite.existing_failures)) end + expose :new_errors, using: TestCaseEntity do |suite| + suite.new_errors.take(max_tests) + end + + expose :existing_errors, using: TestCaseEntity do |suite| + suite.existing_errors.take( + max_tests(suite.new_errors)) + end + + expose :resolved_errors, using: TestCaseEntity do |suite| + suite.resolved_errors.take( + max_tests(suite.new_errors, suite.existing_errors)) + end + private def max_tests(*used) diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb index 8b19925f153..017035fa117 100644 --- a/app/serializers/variable_entity.rb +++ b/app/serializers/variable_entity.rb @@ -4,6 +4,7 @@ class VariableEntity < Grape::Entity expose :id expose :key expose :value + expose :variable_type expose :protected?, as: :protected expose :masked?, as: :masked diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb deleted file mode 100644 index d8098c4a8f5..00000000000 --- a/app/services/akismet_service.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class AkismetService - attr_accessor :text, :options - - def initialize(owner_name, owner_email, text, options = {}) - @owner_name = owner_name - @owner_email = owner_email - @text = text - @options = options - end - - def spam? - return false unless akismet_enabled? - - params = { - type: 'comment', - text: text, - created_at: DateTime.now, - author: owner_name, - author_email: owner_email, - referrer: options[:referrer] - } - - begin - is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) - is_spam || is_blatant - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") # rubocop:disable Gitlab/RailsLogger - false - end - end - - def submit_ham - submit(:ham) - end - - def submit_spam - submit(:spam) - end - - private - - attr_accessor :owner_name, :owner_email - - def akismet_client - @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key, - Gitlab.config.gitlab.url) - end - - def akismet_enabled? - Gitlab::CurrentSettings.akismet_enabled - end - - def submit(type) - return false unless akismet_enabled? - - params = { - type: 'comment', - text: text, - author: owner_name, - author_email: owner_email - } - - begin - akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend - true - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") # rubocop:disable Gitlab/RailsLogger - false - end - end -end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 40761ee97d2..9fd892ead82 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -44,6 +44,8 @@ class AuditEventService end def log_security_event_to_database + return if Gitlab::Database.read_only? + SecurityEvent.create(base_payload.merge(details: @details)) end end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index a9240e1d8a0..699fa17cb65 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -10,7 +10,7 @@ module Boards end def execute - fetch_issues.order_by_position_and_priority + fetch_issues.order_by_position_and_priority(with_cte: can_attempt_search_optimization?) end # rubocop: disable CodeReuse/ActiveRecord @@ -63,6 +63,7 @@ module Boards set_state set_scope set_non_archived + set_attempt_search_optimizations params end @@ -87,6 +88,16 @@ module Boards params[:non_archived] = parent.is_a?(Group) end + def set_attempt_search_optimizations + return unless can_attempt_search_optimization? + + if board.group_board? + params[:attempt_group_search_optimizations] = true + else + params[:attempt_project_search_optimizations] = true + end + end + # rubocop: disable CodeReuse/ActiveRecord def board_label_ids @board_label_ids ||= board.lists.movable.pluck(:label_id) @@ -113,6 +124,15 @@ module Boards .where("label_links.label_id = ?", list.label_id).limit(1)) end # rubocop: enable CodeReuse/ActiveRecord + + def board_group + board.group_board? ? parent : parent.group + end + + def can_attempt_search_optimization? + params[:search].present? && + Feature.enabled?(:board_search_optimization, board_group, default_enabled: false) + end end end end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb index 8258d5d07d3..729bca6580e 100644 --- a/app/services/boards/list_service.rb +++ b/app/services/boards/list_service.rb @@ -2,16 +2,10 @@ module Boards class ListService < Boards::BaseService - def execute - create_board! if parent.boards.empty? - - if parent.multiple_issue_boards_available? - boards - else - # When multiple issue boards are not available - # a user is only allowed to view the default shown board - first_board - end + def execute(create_default_board: true) + create_board! if create_default_board && parent.boards.empty? + + find_boards end private @@ -27,5 +21,18 @@ module Boards def create_board! Boards::CreateService.new(parent, current_user).execute end + + def find_boards + found = + if parent.multiple_issue_boards_available? + boards + else + # When multiple issue boards are not available + # a user is only allowed to view the default shown board + first_board + end + + params[:board_id].present? ? [found.find(params[:board_id])] : found + end end end diff --git a/app/services/ci/create_cross_project_pipeline_service.rb b/app/services/ci/create_cross_project_pipeline_service.rb new file mode 100644 index 00000000000..8de72ace261 --- /dev/null +++ b/app/services/ci/create_cross_project_pipeline_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Ci + # TODO: rename this (and worker) to CreateDownstreamPipelineService + class CreateCrossProjectPipelineService < ::BaseService + include Gitlab::Utils::StrongMemoize + + def execute(bridge) + @bridge = bridge + + pipeline_params = @bridge.downstream_pipeline_params + target_ref = pipeline_params.dig(:target_revision, :ref) + + return unless ensure_preconditions!(target_ref) + + service = ::Ci::CreatePipelineService.new( + pipeline_params.fetch(:project), + current_user, + pipeline_params.fetch(:target_revision)) + + service.execute( + pipeline_params.fetch(:source), pipeline_params[:execute_params]) do |pipeline| + @bridge.sourced_pipelines.build( + source_pipeline: @bridge.pipeline, + source_project: @bridge.project, + project: @bridge.downstream_project, + pipeline: pipeline) + + pipeline.variables.build(@bridge.downstream_variables) + end + end + + private + + def ensure_preconditions!(target_ref) + unless downstream_project_accessible? + @bridge.drop!(:downstream_bridge_project_not_found) + return false + end + + # TODO: Remove this condition if favour of model validation + # https://gitlab.com/gitlab-org/gitlab/issues/38338 + if downstream_project == project && !@bridge.triggers_child_pipeline? + @bridge.drop!(:invalid_bridge_trigger) + return false + end + + # TODO: Remove this condition if favour of model validation + # https://gitlab.com/gitlab-org/gitlab/issues/38338 + if @bridge.triggers_child_pipeline? && @bridge.pipeline.parent_pipeline.present? + @bridge.drop!(:bridge_pipeline_is_child_pipeline) + return false + end + + unless can_create_downstream_pipeline?(target_ref) + @bridge.drop!(:insufficient_bridge_permissions) + return false + end + + true + end + + def downstream_project_accessible? + downstream_project.present? && + can?(current_user, :read_project, downstream_project) + end + + def can_create_downstream_pipeline?(target_ref) + can?(current_user, :update_pipeline, project) && + can?(current_user, :create_pipeline, downstream_project) && + can_update_branch?(target_ref) + end + + def can_update_branch?(target_ref) + ::Gitlab::UserAccess.new(current_user, project: downstream_project).can_update_branch?(target_ref) + end + + def downstream_project + strong_memoize(:downstream_project) do + @bridge.downstream_project + end + end + end +end diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb new file mode 100644 index 00000000000..e633dc7f633 --- /dev/null +++ b/app/services/ci/create_job_artifacts_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Ci + class CreateJobArtifactsService + ArtifactsExistError = Class.new(StandardError) + + def execute(job, artifacts_file, params, metadata_file: nil) + expire_in = params['expire_in'] || + Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in + + job.job_artifacts.build( + project: job.project, + file: artifacts_file, + file_type: params['artifact_type'], + file_format: params['artifact_format'], + file_sha256: artifacts_file.sha256, + expire_in: expire_in) + + if metadata_file + job.job_artifacts.build( + project: job.project, + file: metadata_file, + file_type: :metadata, + file_format: :gzip, + file_sha256: metadata_file.sha256, + expire_in: expire_in) + end + + job.update(artifacts_expire_in: expire_in) + rescue ActiveRecord::RecordNotUnique => error + return true if sha256_matches_existing_artifact?(job, params['artifact_type'], artifacts_file) + + Gitlab::ErrorTracking.track_exception(error, + job_id: job.id, + project_id: job.project_id, + uploading_type: params['artifact_type'] + ) + + job.errors.add(:base, 'another artifact of the same type already exists') + false + end + + private + + def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file) + existing_artifact = job.job_artifacts.find_by_file_type(artifact_type) + return false unless existing_artifact + + existing_artifact.file_sha256 == artifacts_file.sha256 + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 2daf3a51235..52977034b70 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -61,7 +61,7 @@ module Ci Ci::ProcessPipelineService .new(pipeline) - .execute + .execute(nil, initial_process: true) end end diff --git a/app/services/ci/pipeline_bridge_status_service.rb b/app/services/ci/pipeline_bridge_status_service.rb new file mode 100644 index 00000000000..19ed5026a3a --- /dev/null +++ b/app/services/ci/pipeline_bridge_status_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineBridgeStatusService < ::BaseService + def execute(pipeline) + return unless pipeline.bridge_triggered? + + pipeline.source_bridge.inherit_status_from_downstream!(pipeline) + end + end +end + +Ci::PipelineBridgeStatusService.prepend_if_ee('EE::Ci::PipelineBridgeStatusService') diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 1ed295f5950..55846c3cb5c 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -93,9 +93,9 @@ module Ci end def processable_status(processable) - if needs_names = processable.aggregated_needs_names + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && processable.scheduling_type_dag? # Processable uses DAG, get status of all dependent needs - @collection.status_for_names(needs_names) + @collection.status_for_names(processable.aggregated_needs_names.to_a) else # Processable uses Stages, get status of prior stage @collection.status_for_prior_stage_position(processable.stage_idx.to_i) diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb index 400dc9f0abb..278fba20283 100644 --- a/app/services/ci/pipeline_processing/legacy_processing_service.rb +++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb @@ -11,12 +11,13 @@ module Ci @pipeline = pipeline end - def execute(trigger_build_ids = nil) - success = process_stages_without_needs + def execute(trigger_build_ids = nil, initial_process: false) + success = process_stages_for_stage_scheduling # we evaluate dependent needs, # only when the another job has finished - success = process_builds_with_needs(trigger_build_ids) || success + success = process_dag_builds_without_needs || success if initial_process + success = process_dag_builds_with_needs(trigger_build_ids) || success @pipeline.update_legacy_status @@ -25,23 +26,31 @@ module Ci private - def process_stages_without_needs - stage_indexes_of_created_processables_without_needs.flat_map do |index| - process_stage_without_needs(index) + def process_stages_for_stage_scheduling + stage_indexes_of_created_stage_scheduled_processables.flat_map do |index| + process_stage_for_stage_scheduling(index) end.any? end - def process_stage_without_needs(index) + def process_stage_for_stage_scheduling(index) current_status = status_for_prior_stages(index) return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - created_processables_in_stage_without_needs(index).find_each.select do |build| + created_stage_scheduled_processables_in_stage(index).find_each.select do |build| process_build(build, current_status) end.any? end - def process_builds_with_needs(trigger_build_ids) + def process_dag_builds_without_needs + return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) + + created_processables.scheduling_type_dag.without_needs.each do |build| + process_build(build, 'success') + end + end + + def process_dag_builds_with_needs(trigger_build_ids) return false unless trigger_build_ids.present? return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) @@ -56,14 +65,15 @@ module Ci # Each found processable is guaranteed here to have completed status created_processables + .scheduling_type_dag .with_needs(trigger_build_names) .without_needs(incomplete_build_names) .find_each - .map(&method(:process_build_with_needs)) + .map(&method(:process_dag_build_with_needs)) .any? end - def process_build_with_needs(build) + def process_dag_build_with_needs(build) current_status = status_for_build_needs(build.needs.map(&:name)) return unless HasStatus::COMPLETED_STATUSES.include?(current_status) @@ -87,23 +97,23 @@ module Ci end # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_processables_without_needs - created_processables_without_needs.order(:stage_idx) + def stage_indexes_of_created_stage_scheduled_processables + created_stage_scheduled_processables.order(:stage_idx) .pluck(Arel.sql('DISTINCT stage_idx')) end # rubocop: enable CodeReuse/ActiveRecord - def created_processables_in_stage_without_needs(index) - created_processables_without_needs + def created_stage_scheduled_processables_in_stage(index) + created_stage_scheduled_processables .with_preloads .for_stage(index) end - def created_processables_without_needs + def created_stage_scheduled_processables if Feature.enabled?(:ci_dag_support, project, default_enabled: true) - pipeline.processables.created.without_needs + created_processables.scheduling_type_stage else - pipeline.processables.created + created_processables end end diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index eb92c7d1a27..12cdca24066 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -3,7 +3,7 @@ module Ci class ProcessBuildService < BaseService def execute(build, current_status) - if valid_statuses_for_when(build.when).include?(current_status) + if valid_statuses_for_build(build).include?(current_status) if build.schedulable? build.schedule elsif build.action? @@ -25,10 +25,10 @@ module Ci build.enqueue end - def valid_statuses_for_when(value) - case value + def valid_statuses_for_build(build) + case build.when when 'on_success' - %w[success skipped] + build.scheduling_type_dag? ? %w[success] : %w[success skipped] when 'on_failure' %w[failed] when 'always' diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 1ecef256233..d1efa19eb0d 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -8,8 +8,9 @@ module Ci @pipeline = pipeline end - def execute(trigger_build_ids = nil) + def execute(trigger_build_ids = nil, initial_process: false) update_retried + ensure_scheduling_type_for_processables if Feature.enabled?(:ci_atomic_processing, pipeline.project) Ci::PipelineProcessing::AtomicProcessingService @@ -18,7 +19,7 @@ module Ci else Ci::PipelineProcessing::LegacyProcessingService .new(pipeline) - .execute(trigger_build_ids) + .execute(trigger_build_ids, initial_process: initial_process) end end @@ -43,5 +44,17 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord + + # Set scheduling type of processables if they were created before scheduling_type + # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246). + # Given that this service runs multiple times during the pipeline + # life cycle we need to ensure we populate the data once. + # See more: https://gitlab.com/gitlab-org/gitlab/issues/205426 + def ensure_scheduling_type_for_processables + lease = Gitlab::ExclusiveLease.new("set-scheduling-types:#{pipeline.id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + pipeline.processables.populate_scheduling_type! + end end end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 1f00d54b6a7..838ed789155 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -5,7 +5,7 @@ module Ci CLONE_ACCESSORS = %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex - description tag_list protected needs resource_group].freeze + description tag_list protected needs resource_group scheduling_type].freeze def execute(build) reprocess!(build).tap do |new_build| @@ -27,9 +27,10 @@ module Ci attributes = CLONE_ACCESSORS.map do |attribute| [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend - end + end.to_h - attributes.push([:user, current_user]) + attributes[:user] = current_user + attributes[:scheduling_type] ||= build.find_legacy_scheduling_type Ci::Build.transaction do # mark all other builds of that name as retried @@ -49,7 +50,7 @@ module Ci private def create_build!(attributes) - build = project.builds.new(Hash[attributes]) + build = project.builds.new(attributes) build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource build.retried = false build.save! diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 7d01de9ee68..9bb236ac44c 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -36,7 +36,7 @@ module Ci Ci::ProcessPipelineService .new(pipeline) - .execute(completed_build_ids) + .execute(completed_build_ids, initial_process: true) end end end diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index d9a800791f2..14ef744ada1 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -16,6 +16,22 @@ module Ci merge_request.environments.each { |environment| stop(environment) } end + ## + # This method is for stopping multiple environments in a batch style. + # The maximum acceptable count of environments is roughly 5000. Please + # apply acceptable `LIMIT` clause to the `environments` relation. + def self.execute_in_batch(environments) + stop_actions = environments.stop_actions.load + + environments.update_all(auto_stop_at: nil, state: 'stopped') + + stop_actions.each do |stop_action| + stop_action.play(stop_action.user) + rescue => e + Gitlab::ErrorTracking.track_error(e, deployable_id: stop_action.id) + end + end + private def environments diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index 844da11e5cb..2585d815e07 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -58,7 +58,7 @@ module Clusters end def instantiate_application - raise_invalid_application_error if invalid_application? + raise_invalid_application_error if unknown_application? builder || raise(InvalidApplicationError, "invalid application: #{application_name}") end @@ -67,10 +67,6 @@ module Clusters raise(InvalidApplicationError, "invalid application: #{application_name}") end - def invalid_application? - unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) - end - def unknown_application? Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name) end diff --git a/app/services/clusters/kubernetes.rb b/app/services/clusters/kubernetes.rb index d29519999b2..aafea64c820 100644 --- a/app/services/clusters/kubernetes.rb +++ b/app/services/clusters/kubernetes.rb @@ -12,5 +12,7 @@ module Clusters GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding' GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role' GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding' + KNATIVE_SERVING_NAMESPACE = 'knative-serving' + ISTIO_SYSTEM_NAMESPACE = 'istio-system' end end diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb new file mode 100644 index 00000000000..fe577beaa8a --- /dev/null +++ b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'openssl' + +module Clusters + module Kubernetes + class ConfigureIstioIngressService + PASSTHROUGH_RESOURCE = Kubeclient::Resource.new( + mode: 'PASSTHROUGH' + ).freeze + + MTLS_RESOURCE = Kubeclient::Resource.new( + mode: 'MUTUAL', + privateKey: '/etc/istio/ingressgateway-certs/tls.key', + serverCertificate: '/etc/istio/ingressgateway-certs/tls.crt', + caCertificates: '/etc/istio/ingressgateway-ca-certs/cert.pem' + ).freeze + + def initialize(cluster:) + @cluster = cluster + @platform = cluster.platform + @kubeclient = platform.kubeclient + @knative = cluster.application_knative + end + + def execute + return configure_certificates if serverless_domain_cluster + + configure_passthrough + end + + private + + attr_reader :cluster, :platform, :kubeclient, :knative + + def serverless_domain_cluster + knative&.serverless_domain_cluster + end + + def configure_certificates + create_or_update_istio_cert_and_key + set_gateway_wildcard_https(MTLS_RESOURCE) + end + + def create_or_update_istio_cert_and_key + name = OpenSSL::X509::Name.parse("CN=#{knative.hostname}") + + key = OpenSSL::PKey::RSA.new(2048) + + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 0 + cert.not_before = Time.now + cert.not_after = Time.now + 1000.years + + cert.public_key = key.public_key + cert.subject = name + cert.issuer = name + cert.sign(key, OpenSSL::Digest::SHA256.new) + + serverless_domain_cluster.update!( + key: key.to_pem, + certificate: cert.to_pem + ) + + kubeclient.create_or_update_secret(istio_ca_certs_resource) + kubeclient.create_or_update_secret(istio_certs_resource) + end + + def istio_ca_certs_resource + Gitlab::Kubernetes::GenericSecret.new( + 'istio-ingressgateway-ca-certs', + { + 'cert.pem': Base64.strict_encode64(serverless_domain_cluster.certificate) + }, + Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE + ).generate + end + + def istio_certs_resource + Gitlab::Kubernetes::TlsSecret.new( + 'istio-ingressgateway-certs', + serverless_domain_cluster.certificate, + serverless_domain_cluster.key, + Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE + ).generate + end + + def set_gateway_wildcard_https(tls_resource) + gateway_resource = gateway + gateway_resource.spec.servers.each do |server| + next unless server.hosts == ['*'] && server.port.name == 'https' + + server.tls = tls_resource + end + kubeclient.update_gateway(gateway_resource) + end + + def configure_passthrough + set_gateway_wildcard_https(PASSTHROUGH_RESOURCE) + end + + def gateway + kubeclient.get_gateway('knative-ingress-gateway', Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE) + end + end + end +end diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb index 4c5b15b2f95..91a18909e22 100644 --- a/app/services/commits/cherry_pick_service.rb +++ b/app/services/commits/cherry_pick_service.rb @@ -3,7 +3,24 @@ module Commits class CherryPickService < ChangeService def create_commit! - commit_change(:cherry_pick) + commit_change(:cherry_pick).tap do |sha| + track_mr_picking(sha) + end + end + + private + + def track_mr_picking(pick_sha) + return unless Feature.enabled?(:track_mr_picking, project) + + merge_request = project.merge_requests.by_merge_commit_sha(@commit.sha).first + return unless merge_request + + ::SystemNotes::MergeRequestsService.new( + noteable: merge_request, + project: project, + author: current_user + ).picked_into_branch(@branch_name, pick_sha) end end end diff --git a/app/services/concerns/akismet_methods.rb b/app/services/concerns/akismet_methods.rb index 1cbcf0d47b9..105b79785bd 100644 --- a/app/services/concerns/akismet_methods.rb +++ b/app/services/concerns/akismet_methods.rb @@ -2,23 +2,14 @@ module AkismetMethods def spammable_owner - @user ||= User.find(spammable_owner_id) - end - - def spammable_owner_id - @owner_id ||= - if spammable.respond_to?(:author_id) - spammable.author_id - elsif spammable.respond_to?(:creator_id) - spammable.creator_id - end + @user ||= User.find(spammable.author_id) end def akismet - @akismet ||= AkismetService.new( + @akismet ||= Spam::AkismetService.new( spammable_owner.name, spammable_owner.email, - spammable.spammable_text, + spammable.try(:spammable_text) || spammable&.text, options ) end diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb index 75d9759f1d1..695bdf92b49 100644 --- a/app/services/concerns/spam_check_methods.rb +++ b/app/services/concerns/spam_check_methods.rb @@ -22,14 +22,15 @@ module SpamCheckMethods # a dirty instance, which means it should be already assigned with the new # attribute values. # rubocop:disable Gitlab/ModuleWithInstanceVariables - # rubocop: disable CodeReuse/ActiveRecord def spam_check(spammable, user) - spam_service = SpamService.new(spammable: spammable, request: @request) - - spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do - user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true) - end + Spam::SpamCheckService.new( + spammable: spammable, + request: @request + ).execute( + api: @api, + recaptcha_verified: @recaptcha_verified, + spam_log_id: @spam_log_id, + user_id: user.id) end - # rubocop: enable CodeReuse/ActiveRecord # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb index 5d141d4d64d..82274fd8668 100644 --- a/app/services/container_expiration_policy_service.rb +++ b/app/services/container_expiration_policy_service.rb @@ -6,9 +6,11 @@ class ContainerExpirationPolicyService < BaseService container_expiration_policy.container_repositories.find_each do |container_repository| CleanupContainerRepositoryWorker.perform_async( - current_user.id, + nil, container_repository.id, - container_expiration_policy.attributes.except("created_at", "updated_at") + container_expiration_policy.attributes + .except('created_at', 'updated_at') + .merge(container_expiration_policy: true) ) end end diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb index a1d6d50bbb4..67a2230350d 100644 --- a/app/services/deployments/link_merge_requests_service.rb +++ b/app/services/deployments/link_merge_requests_service.rb @@ -38,6 +38,8 @@ module Deployments .commits_between(from, to) .map(&:id) + track_mr_picking = Feature.enabled?(:track_mr_picking, project) + # For some projects the list of commits to deploy may be very large. To # ensure we do not end up running SQL queries with thousands of WHERE IN # values, we run one query per a certain number of commits. @@ -50,6 +52,13 @@ module Deployments project.merge_requests.merged.by_merge_commit_sha(slice) deployment.link_merge_requests(merge_requests) + + next unless track_mr_picking + + picked_merge_requests = + project.merge_requests.by_cherry_pick_sha(slice) + + deployment.link_merge_requests(picked_merge_requests) end end diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb new file mode 100644 index 00000000000..122f8ac89ed --- /dev/null +++ b/app/services/deployments/older_deployments_drop_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Deployments + class OlderDeploymentsDropService + attr_reader :deployment + + def initialize(deployment_id) + @deployment = Deployment.find_by_id(deployment_id) + end + + def execute + return unless @deployment&.running? + + older_deployments.find_each do |older_deployment| + older_deployment.deployable&.drop!(:forward_deployment_failure) + rescue => e + Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id) + end + end + + private + + def older_deployments + @deployment + .environment + .active_deployments + .older_than(@deployment) + .with_deployable + end + end +end diff --git a/app/services/environments/auto_stop_service.rb b/app/services/environments/auto_stop_service.rb new file mode 100644 index 00000000000..ee7f25a4d76 --- /dev/null +++ b/app/services/environments/auto_stop_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Environments + class AutoStopService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::LoopHelpers + + BATCH_SIZE = 100 + LOOP_TIMEOUT = 45.minutes + LOOP_LIMIT = 1000 + EXCLUSIVE_LOCK_KEY = 'environments:auto_stop:lock' + LOCK_TIMEOUT = 50.minutes + + ## + # Stop expired environments on GitLab instance + # + # This auto stop process cannot run for more than 45 minutes. This is for + # preventing multiple `AutoStopCronWorker` CRON jobs run concurrently, + # which is scheduled at every hour. + def execute + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + stop_in_batch + end + end + end + + private + + def stop_in_batch + environments = Environment.auto_stoppable(BATCH_SIZE) + + return false unless environments.exists? && Feature.enabled?(:auto_stop_environments, default_enabled: true) + + Ci::StopEnvironmentsService.execute_in_batch(environments) + end + end +end diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb index 430d9952332..289c125b9d1 100644 --- a/app/services/error_tracking/base_service.rb +++ b/app/services/error_tracking/base_service.rb @@ -3,36 +3,33 @@ module ErrorTracking class BaseService < ::BaseService def execute - unauthorized = check_permissions return unauthorized if unauthorized - begin - response = fetch - rescue Sentry::Client::Error => e - return error(e.message, :bad_request) - rescue Sentry::Client::MissingKeysError => e - return error(e.message, :internal_server_error) - end - - errors = parse_errors(response) - return errors if errors - - success(parse_response(response)) + perform end private - def fetch + def perform raise NotImplementedError, "#{self.class} does not implement #{__method__}" end + def compose_response(response, &block) + errors = parse_errors(response) + return errors if errors + + yield if block_given? + + success(parse_response(response)) + end + def parse_response(response) raise NotImplementedError, "#{self.class} does not implement #{__method__}" end - def check_permissions + def unauthorized return error('Error Tracking is not enabled') unless enabled? return error('Access denied', :unauthorized) unless can_read? end @@ -62,5 +59,9 @@ module ErrorTracking def can_read? can?(current_user, :read_sentry_issue, project) end + + def can_update? + can?(current_user, :update_sentry_issue, project) + end end end diff --git a/app/services/error_tracking/issue_details_service.rb b/app/services/error_tracking/issue_details_service.rb index 368cd4517fc..0068a9e9b6d 100644 --- a/app/services/error_tracking/issue_details_service.rb +++ b/app/services/error_tracking/issue_details_service.rb @@ -2,10 +2,35 @@ module ErrorTracking class IssueDetailsService < ErrorTracking::BaseService + include Gitlab::Routing + include Gitlab::Utils::StrongMemoize + private - def fetch - project_error_tracking_setting.issue_details(issue_id: params[:issue_id]) + def perform + response = project_error_tracking_setting.issue_details(issue_id: params[:issue_id]) + + compose_response(response) do + # The gitlab_issue attribute can contain an absolute GitLab url from the Sentry Client + # here we overwrite that in favor of our own data if we have it + response[:issue].gitlab_issue = gitlab_issue_url if gitlab_issue_url + end + end + + def gitlab_issue_url + strong_memoize(:gitlab_issue_url) do + # Use the absolute url to match the GitLab issue url that the Sentry api provides + project_issue_url(project, gitlab_issue.iid) if gitlab_issue + end + end + + def gitlab_issue + strong_memoize(:gitlab_issue) do + SentryIssueFinder + .new(project, current_user: current_user) + .execute(params[:issue_id]) + &.issue + end end def parse_response(response) diff --git a/app/services/error_tracking/issue_latest_event_service.rb b/app/services/error_tracking/issue_latest_event_service.rb index b6ad8f8028b..a39f1cde1b2 100644 --- a/app/services/error_tracking/issue_latest_event_service.rb +++ b/app/services/error_tracking/issue_latest_event_service.rb @@ -4,8 +4,10 @@ module ErrorTracking class IssueLatestEventService < ErrorTracking::BaseService private - def fetch - project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id]) + def perform + response = project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id]) + + compose_response(response) end def parse_response(response) diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb index e433b4a11f2..e516ac95138 100644 --- a/app/services/error_tracking/issue_update_service.rb +++ b/app/services/error_tracking/issue_update_service.rb @@ -4,11 +4,53 @@ module ErrorTracking class IssueUpdateService < ErrorTracking::BaseService private - def fetch - project_error_tracking_setting.update_issue( + def perform + response = project_error_tracking_setting.update_issue( issue_id: params[:issue_id], params: update_params ) + + compose_response(response) do + response[:closed_issue_iid] = update_related_issue&.iid + end + end + + def update_related_issue + issue = related_issue + return unless issue + + close_and_create_note(issue) + end + + def close_and_create_note(issue) + return unless resolving? && issue.opened? + + processed_issue = close_issue(issue) + return unless processed_issue.reset.closed? + + create_system_note(processed_issue) + processed_issue + end + + def close_issue(issue) + Issues::CloseService + .new(project, current_user) + .execute(issue, system_note: false) + end + + def create_system_note(issue) + SystemNoteService.close_after_error_tracking_resolve(issue, project, current_user) + end + + def related_issue + SentryIssueFinder + .new(project, current_user: current_user) + .execute(params[:issue_id]) + &.issue + end + + def resolving? + update_params[:status] == 'resolved' end def update_params @@ -16,7 +58,15 @@ module ErrorTracking end def parse_response(response) - { updated: response[:updated].present? } + { + updated: response[:updated].present?, + closed_issue_iid: response[:closed_issue_iid] + } + end + + def unauthorized + return error('Error Tracking is not enabled') unless enabled? + return error('Access denied', :unauthorized) unless can_update? end end end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 132e9dfa7bd..7087e3825d6 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -6,26 +6,45 @@ module ErrorTracking DEFAULT_LIMIT = 20 DEFAULT_SORT = 'last_seen' + # Sentry client supports 'muted' and 'assigned' but GitLab does not + ISSUE_STATUS_VALUES = %w[ + resolved + unresolved + ignored + ].freeze + def external_url project_error_tracking_setting&.sentry_external_url end private - def fetch - project_error_tracking_setting.list_sentry_issues( + def perform + return invalid_status_error unless valid_status? + + response = project_error_tracking_setting.list_sentry_issues( issue_status: issue_status, limit: limit, search_term: params[:search_term].presence, sort: sort, cursor: params[:cursor].presence ) + + compose_response(response) end def parse_response(response) response.slice(:issues, :pagination) end + def invalid_status_error + error('Bad Request: Invalid issue_status', http_status_for(:bad_Request)) + end + + def valid_status? + ISSUE_STATUS_VALUES.include?(issue_status) + end + def issue_status params[:issue_status] || DEFAULT_ISSUE_STATUS end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb index 09a0b952e84..625addaf915 100644 --- a/app/services/error_tracking/list_projects_service.rb +++ b/app/services/error_tracking/list_projects_service.rb @@ -2,18 +2,16 @@ module ErrorTracking class ListProjectsService < ErrorTracking::BaseService - def execute + private + + def perform unless project_error_tracking_setting.valid? return error(project_error_tracking_setting.errors.full_messages.join(', '), :bad_request) end - super - end - - private + response = project_error_tracking_setting.list_sentry_projects - def fetch - project_error_tracking_setting.list_sentry_projects + compose_response(response) end def parse_response(response) diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index a49983a84fc..ea5b2f401b3 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -81,15 +81,17 @@ module Git end def pipeline_params - { - before: oldrev, - after: newrev, - ref: ref, - variables_attributes: generate_vars_from_push_options || [], - push_options: params[:push_options] || {}, - checkout_sha: Gitlab::DataBuilder::Push.checkout_sha( - project.repository, newrev, ref) - } + strong_memoize(:pipeline_params) do + { + before: oldrev, + after: newrev, + ref: ref, + variables_attributes: generate_vars_from_push_options || [], + push_options: params[:push_options] || {}, + checkout_sha: Gitlab::DataBuilder::Push.checkout_sha( + project.repository, newrev, ref) + } + end end def ci_variables_from_push_options @@ -156,12 +158,16 @@ module Git project_path: project.full_path, message: "Error creating pipeline", errors: exception.to_s, - pipeline_params: pipeline_params + pipeline_params: sanitized_pipeline_params } logger.warn(data) end + def sanitized_pipeline_params + pipeline_params.except(:push_options) + end + def logger if Gitlab::Runtime.sidekiq? Sidekiq.logger diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 69f1f9eb31f..e1cc1f8c834 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -6,7 +6,7 @@ module Git execute_branch_hooks super.tap do - enqueue_update_gpg_signatures + enqueue_update_signatures end end @@ -103,14 +103,22 @@ module Git end end - def enqueue_update_gpg_signatures - unsigned = GpgSignature.unsigned_commit_shas(limited_commits.map(&:sha)) + def unsigned_x509_shas(commits) + X509CommitSignature.unsigned_commit_shas(commits.map(&:sha)) + end + + def unsigned_gpg_shas(commits) + GpgSignature.unsigned_commit_shas(commits.map(&:sha)) + end + + def enqueue_update_signatures + unsigned = unsigned_x509_shas(commits) & unsigned_gpg_shas(commits) return if unsigned.empty? signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned) return if signable.empty? - CreateGpgSignatureWorker.perform_async(signable, project.id) + CreateCommitSignatureWorker.perform_async(signable, project.id) end # It's not sufficient to just check for a blank SHA as it's possible for the diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index 26886fc67dc..2c3975961a8 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -11,6 +11,12 @@ module Groups end def execute + unless @current_user.can?(:admin_group, @group) + raise ::Gitlab::ImportExport::Error.new( + "User with ID: %s does not have permission to Group %s with ID: %s." % + [@current_user.id, @group.name, @group.id]) + end + save! end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb new file mode 100644 index 00000000000..628c8f5bac0 --- /dev/null +++ b/app/services/groups/import_export/import_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Groups + module ImportExport + class ImportService + attr_reader :current_user, :group, :params + + def initialize(group:, user:) + @group = group + @current_user = user + @shared = Gitlab::ImportExport::Shared.new(@group) + end + + def execute + validate_user_permissions + + if import_file && restorer.restore + @group + else + raise StandardError.new(@shared.errors.to_sentence) + end + rescue => e + raise StandardError.new(e.message) + ensure + remove_import_file + end + + private + + def import_file + @import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group, + archive_file: nil, + shared: @shared) + end + + def restorer + @restorer ||= Gitlab::ImportExport::GroupTreeRestorer.new(user: @current_user, + shared: @shared, + group: @group, + group_hash: nil) + end + + def remove_import_file + upload = @group.import_export_upload + + return unless upload&.import_file&.file + + upload.remove_import_file! + upload.save! + end + + def validate_user_permissions + unless current_user.can?(:admin_group, group) + raise ::Gitlab::ImportExport::Error.new( + "User with ID: %s does not have permission to Group %s with ID: %s." % + [current_user.id, group.name, group.id]) + end + end + end + end +end diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb deleted file mode 100644 index 0bbdaa47a1b..00000000000 --- a/app/services/ham_service.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class HamService - attr_accessor :spam_log - - def initialize(spam_log) - @spam_log = spam_log - end - - def mark_as_ham! - if akismet.submit_ham - spam_log.update_attribute(:submitted_as_ham, true) - else - false - end - end - - private - - def akismet - user = spam_log.user - @akismet ||= AkismetService.new( - user.name, - user.email, - spam_log.text, - ip_address: spam_log.source_ip, - user_agent: spam_log.user_agent - ) - end -end diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb new file mode 100644 index 00000000000..94b6f037924 --- /dev/null +++ b/app/services/incident_management/create_issue_service.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module IncidentManagement + class CreateIssueService < BaseService + include Gitlab::Utils::StrongMemoize + + INCIDENT_LABEL = { + title: 'incident', + color: '#CC0033', + description: <<~DESCRIPTION.chomp + Denotes a disruption to IT services and \ + the associated issues require immediate attention + DESCRIPTION + }.freeze + + def initialize(project, params) + super(project, User.alert_bot, params) + end + + def execute + return error_with('setting disabled') unless incident_management_setting.create_issue? + return error_with('invalid alert') unless alert.valid? + + issue = create_issue + return error_with(issue_errors(issue)) unless issue.valid? + + success(issue: issue) + end + + private + + def create_issue + issue = do_create_issue(label_ids: issue_label_ids) + + # Create an unlabelled issue if we couldn't create the issue + # due to labels errors. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 + if issue.errors.include?(:labels) + log_label_error(issue) + issue = do_create_issue + end + + issue + end + + def do_create_issue(**params) + Issues::CreateService.new( + project, + current_user, + title: issue_title, + description: issue_description, + **params + ).execute + end + + def issue_title + alert.full_title + end + + def issue_description + horizontal_line = "\n---\n\n" + + [ + alert_summary, + alert_markdown, + issue_template_content + ].compact.join(horizontal_line) + end + + def issue_label_ids + [ + find_or_create_label(**INCIDENT_LABEL) + ].compact.map(&:id) + end + + def find_or_create_label(**params) + Labels::FindOrCreateService + .new(current_user, project, **params) + .execute + end + + def alert_summary + alert.issue_summary_markdown + end + + def alert_markdown + alert.alert_markdown + end + + def alert + strong_memoize(:alert) do + Gitlab::Alerting::Alert.new(project: project, payload: params).present + end + end + + def issue_template_content + incident_management_setting.issue_template_content + end + + def incident_management_setting + strong_memoize(:incident_management_setting) do + project.incident_management_setting || + project.build_incident_management_setting + end + end + + def issue_errors(issue) + issue.errors.full_messages.to_sentence + end + + def log_label_error(issue) + log_info <<~TEXT.chomp + Cannot create incident issue with labels \ + #{issue.labels.map(&:title).inspect} \ + for "#{project.full_name}": #{issue.errors.full_messages.to_sentence}. + Retrying without labels. + TEXT + end + + def error_with(message) + log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}}) + + error(message) + end + end +end diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 334e50c0be5..2b436f6322c 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -12,7 +12,7 @@ module Issuable def execute update_attributes = { labels: cloneable_labels } - milestone = cloneable_milestone + milestone = matching_milestone(original_entity.milestone&.title) update_attributes[:milestone] = milestone if milestone.present? new_entity.update(update_attributes) @@ -23,11 +23,8 @@ module Issuable private - def cloneable_milestone - return unless new_entity.supports_milestone? - - title = original_entity.milestone&.title - return unless title + def matching_milestone(title) + return if title.blank? || !new_entity.supports_milestone? params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id } @@ -49,29 +46,32 @@ module Issuable end def copy_resource_label_events - original_entity.resource_label_events.find_in_batches do |batch| - events = batch.map do |event| - entity_key = new_entity.is_a?(Issue) ? 'issue_id' : 'epic_id' - event.attributes - .except('id', 'reference', 'reference_html') - .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action]) - end + entity_key = new_entity.class.name.underscore.foreign_key - Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, events) + copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action]) end end def copy_resource_weight_events return unless original_entity.respond_to?(:resource_weight_events) - original_entity.resource_weight_events.find_in_batches do |batch| + copy_events(ResourceWeightEvent.table_name, original_entity.resource_weight_events) do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge('issue_id' => new_entity.id) + end + end + + def copy_events(table_name, events_to_copy) + events_to_copy.find_in_batches do |batch| events = batch.map do |event| - event.attributes - .except('id', 'reference', 'reference_html') - .merge('issue_id' => new_entity.id) - end + yield(event) + end.compact - Gitlab::Database.bulk_insert(ResourceWeightEvent.table_name, events) + Gitlab::Database.bulk_insert(table_name, events) end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 6cb84458d9b..830afbf4a43 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -11,10 +11,14 @@ class IssuableBaseService < BaseService @skip_milestone_email = @params.delete(:skip_milestone_email) end - def filter_params(issuable) + def can_admin_issuable?(issuable) ability_name = :"admin_#{issuable.to_ability_name}" - unless can?(current_user, ability_name, issuable) + can?(current_user, ability_name, issuable) + end + + def filter_params(issuable) + unless can_admin_issuable?(issuable) params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) @@ -164,7 +168,7 @@ class IssuableBaseService < BaseService before_create(issuable) issuable_saved = issuable.with_transaction_returning_status do - issuable.save && issuable.store_mentions! + issuable.save end if issuable_saved @@ -229,7 +233,7 @@ class IssuableBaseService < BaseService ensure_milestone_available(issuable) issuable_saved = issuable.with_transaction_returning_status do - issuable.save(touch: should_touch) && issuable.store_mentions! + issuable.save(touch: should_touch) end if issuable_saved diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb new file mode 100644 index 00000000000..bb82fa23468 --- /dev/null +++ b/app/services/merge_requests/add_context_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module MergeRequests + class AddContextService < MergeRequests::BaseService + def execute + return error("You are not allowed to access the requested resource", 403) unless current_user&.can?(:update_merge_request, merge_request) + return error("Context commits: #{duplicates} are already created", 400) unless duplicates.empty? + return error("One or more context commits' sha is not valid.", 400) if commits.size != commit_ids.size + + context_commit_ids = [] + MergeRequestContextCommit.transaction do + context_commit_ids = MergeRequestContextCommit.bulk_insert(context_commit_rows, return_ids: true) + MergeRequestContextCommitDiffFile.bulk_insert(diff_rows(context_commit_ids)) + end + + commits + end + + private + + def raw_repository + project.repository.raw_repository + end + + def merge_request + params[:merge_request] + end + + def commit_ids + params[:commits] + end + + def commits + project.repository.commits_by(oids: commit_ids) + end + + def context_commit_rows + @context_commit_rows ||= build_context_commit_rows(merge_request.id, commits) + end + + def diff_rows(context_commit_ids) + @diff_rows ||= build_diff_rows(raw_repository, commits, context_commit_ids) + end + + def encode_in_base64?(diff_text) + (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) || + diff_text.include?("\0") + end + + def duplicates + existing_oids = merge_request.merge_request_context_commits.map { |commit| commit.sha.to_s } + duplicate_oids = existing_oids.select do |existing_oid| + commit_ids.select { |commit_id| existing_oid.start_with?(commit_id) }.count > 0 + end + + duplicate_oids + end + + def build_context_commit_rows(merge_request_id, commits) + commits.map.with_index do |commit, index| + # generate context commit information for given commit + commit_hash = commit.to_hash.except(:parent_ids) + sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id)) + commit_hash.merge( + merge_request_id: merge_request_id, + relative_order: index, + sha: sha, + authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) + ) + end + end + + def build_diff_rows(raw_repository, commits, context_commit_ids) + diff_rows = [] + diff_order = 0 + + commits.flat_map.with_index do |commit, index| + commit_hash = commit.to_hash.except(:parent_ids) + sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id)) + # generate context commit diff information for given commit + diffs = commit.diffs + + compare = Gitlab::Git::Compare.new( + raw_repository, + diffs.diff_refs.start_sha, + diffs.diff_refs.head_sha + ) + compare.diffs.map do |diff| + diff_hash = diff.to_hash.merge( + sha: sha, + binary: false, + merge_request_context_commit_id: context_commit_ids[index], + relative_order: diff_order + ) + + # Compatibility with old diffs created with Psych. + diff_hash.tap do |hash| + diff_text = hash[:diff] + + if encode_in_base64?(diff_text) + hash[:binary] = true + hash[:diff] = [diff_text].pack('m0') + end + end + + # Increase order for commit so when present the diffs we can use it to keep order + diff_order += 1 + diff_rows << diff_hash + end + end + + diff_rows + end + end +end diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index 9eb11820f7a..8258efba6bf 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -24,7 +24,7 @@ module MergeRequests ## # UpdateMergeRequestsWorker could be retried by an exception. # pipelines for merge request should not be recreated in such case. - return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.triggered_by_merge_request? + return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.merge_request? return false if merge_request.has_no_commits? true diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 9a37a0330fc..4a05d1fd7ef 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -27,6 +27,7 @@ module MergeRequests create_pipeline_for(issuable, current_user) issuable.update_head_pipeline Gitlab::UsageDataCounters::MergeRequestCounter.count(:create) + link_lfs_objects(issuable) super end @@ -64,6 +65,10 @@ module MergeRequests raise Gitlab::Access::AccessDeniedError end end + + def link_lfs_objects(issuable) + LinkLfsObjectsService.new(issuable.target_project).execute(issuable) + end end end diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb index bdb7ec8a7c2..49ec3c7538c 100644 --- a/app/services/merge_requests/delete_non_latest_diffs_service.rb +++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb @@ -13,7 +13,7 @@ module MergeRequests diffs.each_batch(of: BATCH_SIZE) do |relation, index| ids = relation.pluck_primary_key.map { |id| [id] } - DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids) + DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids) # rubocop:disable Scalability/BulkPerformWithContext end end end diff --git a/app/services/merge_requests/link_lfs_objects_service.rb b/app/services/merge_requests/link_lfs_objects_service.rb new file mode 100644 index 00000000000..191da594095 --- /dev/null +++ b/app/services/merge_requests/link_lfs_objects_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module MergeRequests + class LinkLfsObjectsService < ::BaseService + def execute(merge_request, oldrev: merge_request.diff_base_sha, newrev: merge_request.diff_head_sha) + return if merge_request.source_project == project + return if no_changes?(oldrev, newrev) + + new_lfs_oids = lfs_oids(merge_request.source_project.repository, oldrev, newrev) + + return if new_lfs_oids.empty? + + Projects::LfsPointers::LfsLinkService + .new(project) + .execute(new_lfs_oids) + end + + private + + def no_changes?(oldrev, newrev) + oldrev == newrev + end + + def lfs_oids(source_repository, oldrev, newrev) + Gitlab::Git::LfsChanges + .new(source_repository, newrev) + .new_pointers(not_in: [oldrev]) + .map(&:lfs_oid) + end + end +end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 4a109fe4e16..31097b9151a 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -79,6 +79,8 @@ module MergeRequests end merge_request.update!(merge_commit_sha: commit_id) + ensure + merge_request.update_column(:in_progress_merge_commit_sha, nil) end def try_merge @@ -89,8 +91,6 @@ module MergeRequests rescue => e handle_merge_error(log_message: e.message) raise_error('Something went wrong during merge') - ensure - merge_request.update!(in_progress_merge_commit_sha: nil) end def after_merge diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index 962e2327b3e..5b79e4d01f2 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -12,6 +12,13 @@ module MergeRequests @merge_request = merge_request end + def async_execute + return service_error if service_error + return unless merge_request.mark_as_checking + + MergeRequestMergeabilityCheckWorker.perform_async(merge_request.id) + end + # Updates the MR merge_status. Whenever it switches to a can_be_merged state, # the merge-ref is refreshed. # @@ -30,8 +37,7 @@ module MergeRequests # and the merge-ref is synced. Success in case of being/becoming mergeable, # error otherwise. def execute(recheck: false, retry_lease: true) - return ServiceResponse.error(message: 'Invalid argument') unless merge_request - return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only? + return service_error if service_error return check_mergeability(recheck) unless merge_ref_auto_sync_lock_enabled? in_write_lock(retry_lease: retry_lease) do |retried| @@ -155,5 +161,15 @@ module MergeRequests def merge_ref_auto_sync_lock_enabled? Feature.enabled?(:merge_ref_auto_sync_lock, project, default_enabled: true) end + + def service_error + strong_memoize(:service_error) do + if !merge_request + ServiceResponse.error(message: 'Invalid argument') + elsif Gitlab::Database.read_only? + ServiceResponse.error(message: 'Unsupported operation') + end + end + end end end diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb index 16050244637..89b1e594c95 100644 --- a/app/services/merge_requests/migrate_external_diffs_service.rb +++ b/app/services/merge_requests/migrate_external_diffs_service.rb @@ -9,7 +9,10 @@ module MergeRequests def self.enqueue! ids = MergeRequestDiff.ids_for_external_storage_migration(limit: MAX_JOBS) + # rubocop:disable Scalability/BulkPerformWithContext + # https://gitlab.com/gitlab-org/gitlab/issues/202100 MigrateExternalDiffsWorker.bulk_perform_async(ids.map { |id| [id] }) + # rubocop:enable Scalability/BulkPerformWithContext end def initialize(merge_request_diff) diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 396ddec6383..c6e1651fa26 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -21,6 +21,7 @@ module MergeRequests # empty diff during a manual merge close_upon_missing_source_branch_ref post_merge_manually_merged + link_forks_lfs_objects reload_merge_requests outdate_suggestions refresh_pipelines_on_merge_requests @@ -91,17 +92,25 @@ module MergeRequests end # rubocop: enable CodeReuse/ActiveRecord + # Link LFS objects that exists in forks but does not exists in merge requests + # target project + def link_forks_lfs_objects + return unless @push.branch_updated? + + merge_requests_for_forks.find_each do |mr| + LinkLfsObjectsService + .new(mr.target_project) + .execute(mr, oldrev: @push.oldrev, newrev: @push.newrev) + end + end + # Refresh merge request diff if we push to source or target branch of merge request # Note: we should update merge requests from forks too - # rubocop: disable CodeReuse/ActiveRecord def reload_merge_requests merge_requests = @project.merge_requests.opened .by_source_or_target_branch(@push.branch_name).to_a - # Fork merge requests - merge_requests += MergeRequest.opened - .where(source_branch: @push.branch_name, source_project: @project) - .where.not(target_project: @project).to_a + merge_requests += merge_requests_for_forks.to_a filter_merge_requests(merge_requests).each do |merge_request| if branch_and_project_match?(merge_request) || @push.force_push? @@ -117,7 +126,6 @@ module MergeRequests # @source_merge_requests diffs (for MergeRequest#commit_shas for instance). merge_requests_for_source_branch(reload: true) end - # rubocop: enable CodeReuse/ActiveRecord def push_commit_ids @push_commit_ids ||= @commits.map(&:id) @@ -282,6 +290,15 @@ module MergeRequests @source_merge_requests = nil if reload @source_merge_requests ||= merge_requests_for(@push.branch_name) end + + # rubocop: disable CodeReuse/ActiveRecord + def merge_requests_for_forks + @merge_requests_for_forks ||= + MergeRequest.opened + .where(source_branch: @push.branch_name, source_project: @project) + .where.not(target_project: @project) + end + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb index c51c88d776a..3cd7d8437b1 100644 --- a/app/services/metrics/dashboard/base_service.rb +++ b/app/services/metrics/dashboard/base_service.rb @@ -38,22 +38,22 @@ module Metrics # Determines whether users should be able to view # dashboards at all. def allowed? - if params[:environment] - Ability.allowed?(current_user, :read_environment, project) - elsif params[:cluster] - true # Authorization handled at controller level - else - false - end + return false unless params[:environment] + + Ability.allowed?(current_user, :read_environment, project) end # Returns a new dashboard Hash, supplemented with DB info def process_dashboard ::Gitlab::Metrics::Dashboard::Processor - .new(project, raw_dashboard, sequence, params) + .new(project, raw_dashboard, sequence, process_params) .process end + def process_params + params + end + # @return [String] Relative filepath of the dashboard yml def dashboard_path params[:dashboard_path] diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb index b2ec44cb814..990dc462432 100644 --- a/app/services/metrics/dashboard/clone_dashboard_service.rb +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -8,8 +8,18 @@ module Metrics ALLOWED_FILE_TYPE = '.yml' USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT - def self.allowed_dashboard_templates - @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze + class << self + def allowed_dashboard_templates + @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze + end + + def sequences + @sequences ||= { + ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::ProjectMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::Sorter].freeze + }.freeze + end end def execute @@ -92,7 +102,9 @@ module Metrics end def new_dashboard_content - File.read(Rails.root.join(dashboard_template)) + ::Gitlab::Metrics::Dashboard::Processor + .new(project, raw_dashboard, sequence, {}) + .process.deep_stringify_keys.to_yaml end def repository @@ -106,6 +118,14 @@ module Metrics result end end + + def raw_dashboard + YAML.safe_load(File.read(Rails.root.join(dashboard_template))) + end + + def sequence + self.class.sequences[dashboard_template] + end end end end diff --git a/app/services/metrics/dashboard/default_embed_service.rb b/app/services/metrics/dashboard/default_embed_service.rb index e1bd98bd5c2..39f7c3943dd 100644 --- a/app/services/metrics/dashboard/default_embed_service.rb +++ b/app/services/metrics/dashboard/default_embed_service.rb @@ -20,6 +20,12 @@ module Metrics system_metrics_kubernetes_container_cores_total ).freeze + class << self + def valid_params?(params) + params[:embedded].present? + end + end + # Returns a new dashboard with only the matching # metrics from the system dashboard, stripped of groups. # @return [Hash] diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb index 1be1a000854..297f00b1be9 100644 --- a/app/services/metrics/dashboard/predefined_dashboard_service.rb +++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb @@ -15,6 +15,10 @@ module Metrics ].freeze class << self + def valid_params?(params) + matching_dashboard?(params[:dashboard_path]) + end + def matching_dashboard?(filepath) filepath == self::DASHBOARD_PATH end diff --git a/app/services/metrics/dashboard/project_dashboard_service.rb b/app/services/metrics/dashboard/project_dashboard_service.rb index b0d54ee9347..fadbe0fae01 100644 --- a/app/services/metrics/dashboard/project_dashboard_service.rb +++ b/app/services/metrics/dashboard/project_dashboard_service.rb @@ -9,6 +9,10 @@ module Metrics DASHBOARD_ROOT = ".gitlab/dashboards" class << self + def valid_params?(params) + params[:dashboard_path].present? + end + def all_dashboard_paths(project) file_finder(project) .list_files_for(DASHBOARD_ROOT) diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb new file mode 100644 index 00000000000..d705c3f3ce5 --- /dev/null +++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Fetches the self monitoring metrics dashboard and formats the output. +# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards. +module Metrics + module Dashboard + class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService + DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml' + DASHBOARD_NAME = 'Default' + + SEQUENCE = [ + STAGES::ProjectMetricsInserter, + STAGES::EndpointInserter, + STAGES::Sorter + ].freeze + + class << self + def valid_params?(params) + matching_dashboard?(params[:dashboard_path]) || self_monitoring_project?(params) + end + + def all_dashboard_paths(_project) + [{ + path: DASHBOARD_PATH, + display_name: DASHBOARD_NAME, + default: true, + system_dashboard: false + }] + end + + def self_monitoring_project?(params) + params[:dashboard_path].nil? && params[:environment]&.project&.self_monitoring? + end + end + end + end +end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index bef65dbe1c2..aa8421e10d5 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -11,6 +11,7 @@ module Metrics SEQUENCE = [ STAGES::CommonMetricsInserter, STAGES::ProjectMetricsInserter, + STAGES::ProjectMetricsDetailsInserter, STAGES::EndpointInserter, STAGES::Sorter ].freeze diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 50dc98b88e9..4a0d85038ee 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -2,7 +2,6 @@ module Notes class CreateService < ::Notes::BaseService - # rubocop:disable Metrics/CyclomaticComplexity def execute note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute @@ -34,7 +33,7 @@ module Notes end note_saved = note.with_transaction_returning_status do - !only_commands && note.save && note.store_mentions! + !only_commands && note.save end if note_saved @@ -67,7 +66,6 @@ module Notes note end - # rubocop:enable Metrics/CyclomaticComplexity private diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 15c556498ec..3070e7b0e53 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -3,14 +3,14 @@ module Notes class UpdateService < BaseService def execute(note) - return note unless note.editable? + return note unless note.editable? && params.present? old_mentioned_users = note.mentioned_users(current_user).to_a note.assign_attributes(params.merge(updated_by: current_user)) note.with_transaction_returning_status do - note.save && note.store_mentions! + note.save end only_commands = false diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb new file mode 100644 index 00000000000..e3818e76c4c --- /dev/null +++ b/app/services/post_receive_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# PostReceiveService class +# +# Used for scheduling related jobs after a push action has been performed +class PostReceiveService + attr_reader :user, :project, :params + + def initialize(user, project, params) + @user = user + @project = project + @params = params + end + + def execute + response = Gitlab::InternalPostReceive::Response.new + + push_options = Gitlab::PushOptions.new(params[:push_options]) + + response.reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease + + PostReceive.perform_async(params[:gl_repository], params[:identifier], + params[:changes], push_options.as_json) + + mr_options = push_options.get(:merge_request) + if mr_options.present? + message = process_mr_push_options(mr_options, project, user, params[:changes]) + response.add_alert_message(message) + end + + broadcast_message = BroadcastMessage.current&.last&.message + response.add_alert_message(broadcast_message) + + response.add_merge_request_urls(merge_request_urls) + + # Neither User nor Project are guaranteed to be returned; an orphaned write deploy + # key could be used + if user && project + redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id) + project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id) + + response.add_basic_message(redirect_message) + response.add_basic_message(project_created_message) + end + + response + end + + def process_mr_push_options(push_options, project, user, changes) + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/61359') + + service = ::MergeRequests::PushOptionsHandlerService.new( + project, user, changes, push_options + ).execute + + if service.errors.present? + push_options_warning(service.errors.join("\n\n")) + end + end + + def push_options_warning(warning) + options = Array.wrap(params[:push_options]).map { |p| "'#{p}'" }.join(' ') + "WARNINGS:\nError encountered with push options #{options}: #{warning}" + end + + def merge_request_urls + ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) + end +end diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb index 6fc15db9b4c..ee2dde8aa7f 100644 --- a/app/services/projects/after_import_service.rb +++ b/app/services/projects/after_import_service.rb @@ -12,7 +12,9 @@ module Projects service = Projects::HousekeepingService.new(@project) service.execute do - repository.delete_all_refs_except(RESERVED_REF_PREFIXES) + import_failure_service.with_retry(action: 'delete_all_refs') do + repository.delete_all_refs_except(RESERVED_REF_PREFIXES) + end end # Right now we don't actually have a way to know if a project @@ -26,8 +28,12 @@ module Projects private + def import_failure_service + Gitlab::ImportExport::ImportFailureService.new(@project) + end + def repository - @repository ||= @project.repository + @project.repository end end end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb new file mode 100644 index 00000000000..4ca3b154e4b --- /dev/null +++ b/app/services/projects/alerting/notify_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Projects + module Alerting + class NotifyService < BaseService + include Gitlab::Utils::StrongMemoize + + def execute(token) + return forbidden unless alerts_service_activated? + return unauthorized unless valid_token?(token) + + process_incident_issues + + ServiceResponse.success + rescue Gitlab::Alerting::NotificationPayloadParser::BadPayloadError + bad_request + end + + private + + delegate :alerts_service, :alerts_service_activated?, to: :project + + def process_incident_issues + IncidentManagement::ProcessAlertWorker + .perform_async(project.id, parsed_payload) + end + + def parsed_payload + Gitlab::Alerting::NotificationPayloadParser.call(params.to_h) + end + + def valid_token?(token) + token == alerts_service.token + end + + def bad_request + ServiceResponse.error(message: 'Bad Request', http_status: 400) + end + + def unauthorized + ServiceResponse.error(message: 'Unauthorized', http_status: 401) + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', http_status: 403) + end + end + end +end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index b995df12e56..046745d725e 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -5,7 +5,7 @@ module Projects class CleanupTagsService < BaseService def execute(container_repository) return error('feature disabled') unless can_use? - return error('access denied') unless can_admin? + return error('access denied') unless can_destroy? tags = container_repository.tags tags_by_digest = group_by_digest(tags) @@ -82,8 +82,10 @@ module Projects end end - def can_admin? - can?(current_user, :admin_container_image, project) + def can_destroy? + return true if params['container_expiration_policy'] + + can?(current_user, :destroy_container_image, project) end def can_use? diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 88ff3c2c9df..21081bd077f 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -14,12 +14,25 @@ module Projects private + # Delete tags by name with a single DELETE request. This is only supported + # by the GitLab Container Registry fork. See + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details. + def fast_delete(container_repository, tag_names) + deleted_tags = tag_names.select do |name| + container_repository.delete_tag_by_name(name) + end + + deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') + end + # Replace a tag on the registry with a dummy tag. # This is a hack as the registry doesn't support deleting individual # tags. This code effectively pushes a dummy image and assigns the tag to it. # This way when the tag is deleted only the dummy image is affected. + # This is used to preverse compatibility with third-party registries that + # don't support fast delete. # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion - def smart_delete(container_repository, tag_names) + def slow_delete(container_repository, tag_names) # generates the blobs for the dummy image dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) return error('could not generate manifest') if dummy_manifest.nil? @@ -29,13 +42,22 @@ module Projects # Deletes the dummy image # All created tag digests are the same since they all have the same dummy image. # a single delete is sufficient to remove all tags with it - if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.values.first) + if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.each_value.first) success(deleted: deleted_tags.keys) else error('could not delete tags') end end + def smart_delete(container_repository, tag_names) + fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true) + if fast_delete_enabled && container_repository.client.supports_tag_delete? + fast_delete(container_repository, tag_names) + else + slow_delete(container_repository, tag_names) + end + end + # update the manifests of the tags with the new dummy image def replace_tag_manifests(container_repository, dummy_manifest, tag_names) deleted_tags = {} diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index ef06545b27d..7bf68e7d315 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -90,6 +90,7 @@ module Projects end @project.track_project_repository + @project.create_project_setting unless @project.project_setting event_service.create_project(@project, current_user) system_hook_service.execute_hooks_for(@project, :create) diff --git a/app/services/projects/destroy_rollback_service.rb b/app/services/projects/destroy_rollback_service.rb new file mode 100644 index 00000000000..7f0ca63a406 --- /dev/null +++ b/app/services/projects/destroy_rollback_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Projects + class DestroyRollbackService < BaseService + include Gitlab::ShellAdapter + + def execute + return unless project + + Projects::ForksCountService.new(project).delete_cache + + unless rollback_repository(project.repository) + raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.')) + end + + unless rollback_repository(project.wiki.repository) + raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.')) + end + end + + private + + def rollback_repository(repository) + return true unless repository + + result = Repositories::DestroyRollbackService.new(repository).execute + + result[:status] == :success + end + end +end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index cbed794f92e..066d1f1ca72 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -6,9 +6,6 @@ module Projects DestroyError = Class.new(StandardError) - DELETED_FLAG = '+deleted' - REPO_REMOVAL_DELAY = 5.minutes.to_i - def async_execute project.update_attribute(:pending_delete, true) @@ -18,7 +15,7 @@ module Projects schedule_stale_repos_removal job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) - Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}") # rubocop:disable Gitlab/RailsLogger + log_info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}") end def execute @@ -48,82 +45,34 @@ module Projects raise end - def attempt_repositories_rollback - return unless @project - - flush_caches(@project) - - unless rollback_repository(removal_path(repo_path), repo_path) - raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.')) - end - - unless rollback_repository(removal_path(wiki_path), wiki_path) - raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.')) - end - end - private - def repo_path - project.disk_path - end - - def wiki_path - project.wiki.disk_path - end - def trash_repositories! - unless remove_repository(repo_path) + unless remove_repository(project.repository) raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.')) end - unless remove_repository(wiki_path) + unless remove_repository(project.wiki.repository) raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.')) end end - def remove_repository(path) - # There is a possibility project does not have repository or wiki - return true unless repo_exists?(path) + def remove_repository(repository) + return true unless repository - new_path = removal_path(path) + result = Repositories::DestroyService.new(repository).execute - if mv_repository(path, new_path) - log_info(%Q{Repository "#{path}" moved to "#{new_path}" for project "#{project.full_path}"}) - - project.run_after_commit do - GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY, :remove_repository, self.repository_storage, new_path) - end - else - false - end + result[:status] == :success end def schedule_stale_repos_removal - repo_paths = [removal_path(repo_path), removal_path(wiki_path)] + repos = [project.repository, project.wiki.repository] - # Ideally it should wait until the regular removal phase finishes, - # so let's delay it a bit further. - repo_paths.each do |path| - GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY * 2, :remove_repository, project.repository_storage, path) - end - end + repos.each do |repository| + next unless repository - def rollback_repository(old_path, new_path) - # There is a possibility project does not have repository or wiki - return true unless repo_exists?(old_path) - - mv_repository(old_path, new_path) - end - - def repo_exists?(path) - gitlab_shell.repository_exists?(project.repository_storage, path + '.git') - end - - def mv_repository(from_path, to_path) - return true unless repo_exists?(from_path) - - gitlab_shell.mv_repository(project.repository_storage, from_path, to_path) + Repositories::ShellDestroyService.new(repository).execute(Repositories::ShellDestroyService::STALE_REMOVAL_DELAY) + end end def attempt_rollback(project, message) @@ -191,32 +140,9 @@ module Projects raise DestroyError.new(message) end - # Build a path for removing repositories - # We use `+` because its not allowed by GitLab so user can not create - # project with name cookies+119+deleted and capture someone stalled repository - # - # gitlab/cookies.git -> gitlab/cookies+119+deleted.git - # - def removal_path(path) - "#{path}+#{project.id}#{DELETED_FLAG}" - end - def flush_caches(project) - ignore_git_errors(repo_path) { project.repository.before_delete } - - ignore_git_errors(wiki_path) { Repository.new(wiki_path, project, disk_path: repo_path).before_delete } - Projects::ForksCountService.new(project).delete_cache end - - # If we get a Gitaly error, the repository may be corrupted. We can - # ignore these errors since we're going to trash the repositories - # anyway. - def ignore_git_errors(disk_path, &block) - yield - rescue Gitlab::Git::CommandError => e - Gitlab::GitLogger.warn(class: self.class.name, project_id: project.id, disk_path: disk_path, message: e.to_s) - end end end diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb index d3680637217..942cd8162e4 100644 --- a/app/services/projects/detect_repository_languages_service.rb +++ b/app/services/projects/detect_repository_languages_service.rb @@ -12,7 +12,7 @@ module Projects matching_programming_languages = ensure_programming_languages(detection) RepositoryLanguage.transaction do - project.repository_languages.where(programming_language_id: detection.deletions).delete_all + RepositoryLanguage.where(project_id: project.id, programming_language_id: detection.deletions).delete_all detection.updates.each do |update| RepositoryLanguage diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index e66a0ed181a..fcfea567885 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -26,17 +26,7 @@ module Projects build_fork_network_member(fork_to_project) - if link_fork_network(fork_to_project) - # A forked project stores its LFS objects in the `forked_from_project`. - # So the LFS objects become inaccessible, and therefore delete them from - # the database so they'll get cleaned up. - # - # TODO: refactor this to get the correct lfs objects when implementing - # https://gitlab.com/gitlab-org/gitlab-foss/issues/39769 - fork_to_project.lfs_objects_projects.delete_all - - fork_to_project - end + fork_to_project if link_fork_network(fork_to_project) end def fork_new_project diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index cc12aacaf02..a4771e864d4 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -66,23 +66,21 @@ module Projects end def import_repository - begin - refmap = importer_class.try(:refmap) if has_importer? - - if refmap - project.ensure_repository - project.repository.fetch_as_mirror(project.import_url, refmap: refmap) - else - gitlab_shell.import_project_repository(project) - end - rescue Gitlab::Shell::Error => e - # Expire cache to prevent scenarios such as: - # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true - # 2. Retried import, repo is broken or not imported but +exists?+ still returns true - project.repository.expire_content_cache if project.repository_exists? + refmap = importer_class.try(:refmap) if has_importer? - raise Error, e.message + if refmap + project.ensure_repository + project.repository.fetch_as_mirror(project.import_url, refmap: refmap) + else + gitlab_shell.import_project_repository(project) end + rescue Gitlab::Shell::Error => e + # Expire cache to prevent scenarios such as: + # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true + # 2. Retried import, repo is broken or not imported but +exists?+ still returns true + project.repository.expire_content_cache if project.repository_exists? + + raise Error, e.message end def download_lfs_objects diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index a009f479d5d..bd70012c76c 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -39,9 +39,9 @@ module Projects def download_lfs_file! with_tmp_file do |tmp_file| download_and_save_file!(tmp_file) - project.all_lfs_objects << LfsObject.new(oid: lfs_oid, - size: lfs_size, - file: tmp_file) + project.lfs_objects << LfsObject.new(oid: lfs_oid, + size: lfs_size, + file: tmp_file) success end diff --git a/app/services/projects/lsif_data_service.rb b/app/services/projects/lsif_data_service.rb new file mode 100644 index 00000000000..971885b680e --- /dev/null +++ b/app/services/projects/lsif_data_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Projects + class LsifDataService + attr_reader :file, :project, :path, :commit_id, + :docs, :doc_ranges, :ranges, :def_refs, :hover_refs + + CACHE_EXPIRE_IN = 1.hour + + def initialize(file, project, params) + @file = file + @project = project + @path = params[:path] + @commit_id = params[:commit_id] + end + + def execute + fetch_data! + + doc_ranges[doc_id]&.map do |range_id| + location, ref_id = ranges[range_id].values_at('loc', 'ref_id') + line_data, column_data = location + + { + start_line: line_data.first, + end_line: line_data.last, + start_char: column_data.first, + end_char: column_data.last, + definition_url: definition_url_for(def_refs[ref_id]), + hover: highlighted_hover(hover_refs[ref_id]) + } + end + end + + private + + def fetch_data + Rails.cache.fetch("project:#{project.id}:lsif:#{commit_id}", expires_in: CACHE_EXPIRE_IN) do + data = nil + + file.open do |stream| + Zlib::GzipReader.wrap(stream) do |gz_stream| + data = JSON.parse(gz_stream.read) + end + end + + data + end + end + + def fetch_data! + data = fetch_data + + @docs = data['docs'] + @doc_ranges = data['doc_ranges'] + @ranges = data['ranges'] + @def_refs = data['def_refs'] + @hover_refs = data['hover_refs'] + end + + def doc_id + @doc_id ||= docs.reduce(nil) do |doc_id, (id, doc_path)| + next doc_id unless doc_path =~ /#{path}$/ + + if doc_id.nil? || docs[doc_id].size > doc_path.size + doc_id = id + end + + doc_id + end + end + + def dir_absolute_path + @dir_absolute_path ||= docs[doc_id]&.delete_suffix(path) + end + + def definition_url_for(ref_id) + return unless range = ranges[ref_id] + + def_doc_id, location = range.values_at('doc_id', 'loc') + localized_doc_url = docs[def_doc_id].delete_prefix(dir_absolute_path) + + # location is stored as [[start_line, end_line], [start_char, end_char]] + start_line = location.first.first + + line_anchor = "L#{start_line + 1}" + definition_ref_path = [commit_id, localized_doc_url].join('/') + + Gitlab::Routing.url_helpers.project_blob_path(project, definition_ref_path, anchor: line_anchor) + end + + def highlighted_hover(hovers) + hovers&.map do |hover| + # Documentation for a method which is added as comments on top of the method + # is stored as a raw string value in LSIF file + next { value: hover } unless hover.is_a?(Hash) + + value = Gitlab::Highlight.highlight(nil, hover['value'], language: hover['language']) + { language: hover['language'], value: value } + end + end + end +end diff --git a/app/services/projects/move_access_service.rb b/app/services/projects/move_access_service.rb index 8e2c3ad2f69..cddc544170f 100644 --- a/app/services/projects/move_access_service.rb +++ b/app/services/projects/move_access_service.rb @@ -20,6 +20,8 @@ module Projects ::Projects::MoveProjectAuthorizationsService.new(@project, @current_user) .execute(source_project, remove_remaining_elements: remove_remaining_elements) + @project.save(touch: false) + success end end diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb index 10e19014db4..8cc420d7ba7 100644 --- a/app/services/projects/move_lfs_objects_projects_service.rb +++ b/app/services/projects/move_lfs_objects_projects_service.rb @@ -16,7 +16,7 @@ module Projects private def move_lfs_objects_projects - non_existent_lfs_objects_projects.update_all(project_id: @project.lfs_storage_project.id) + non_existent_lfs_objects_projects.update_all(project_id: @project.id) end def remove_remaining_lfs_objects_project diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 706a6f01a75..27bbf5c6e57 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -15,6 +15,8 @@ module Projects error_tracking_params .merge(metrics_setting_params) .merge(grafana_integration_params) + .merge(prometheus_integration_params) + .merge(incident_management_setting_params) end def metrics_setting_params @@ -30,6 +32,27 @@ module Projects settings = params[:error_tracking_setting_attributes] return {} if settings.blank? + if error_tracking_params_partial_updates?(settings) + error_tracking_params_for_partial_update(settings) + else + error_tracking_params_for_update(settings) + end + end + + def error_tracking_params_partial_updates?(settings) + # Help from @splattael :bow: + # Make sure we're converting to symbols because + # * ActionController::Parameters#keys returns a list of strings + # * in specs we're using hashes with symbols as keys + + settings.keys.map(&:to_sym) == %i[enabled] + end + + def error_tracking_params_for_partial_update(settings) + { error_tracking_setting_attributes: settings } + end + + def error_tracking_params_for_update(settings) api_url = ::ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( api_host: settings[:api_host], project_slug: settings.dig(:project, :slug), @@ -56,6 +79,19 @@ module Projects { grafana_integration_attributes: attrs.merge(_destroy: destroy) } end + + def prometheus_integration_params + return {} unless attrs = params[:prometheus_integration_attributes] + + service = project.find_or_initialize_service(::PrometheusService.to_param) + service.assign_attributes(attrs) + + { prometheus_service_attributes: service.attributes.except(*%w(id project_id created_at updated_at)) } + end + + def incident_management_setting_params + params.slice(:incident_management_setting_attributes) + end end end end diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb index c5e38f166da..958a00afbb8 100644 --- a/app/services/projects/overwrite_project_service.rb +++ b/app/services/projects/overwrite_project_service.rb @@ -55,13 +55,13 @@ module Projects end def attempt_restore_repositories(project) - ::Projects::DestroyService.new(project, @current_user).attempt_repositories_rollback + ::Projects::DestroyRollbackService.new(project, @current_user).execute end def add_source_project_to_fork_network(source_project) return unless @project.fork_network - # Because he have moved all references in the fork network from the source_project + # Because they have moved all references in the fork network from the source_project # we won't be able to query the database (only through its cached data), # for its former relationships. That's why we're adding it to the network # as a fork of the target project diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 718416a03d4..309eab59463 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -13,8 +13,6 @@ module Projects include Gitlab::ShellAdapter TransferError = Class.new(StandardError) - attr_reader :new_namespace - def execute(new_namespace) @new_namespace = new_namespace @@ -39,6 +37,8 @@ module Projects private + attr_reader :old_path, :new_path, :new_namespace + # rubocop: disable CodeReuse/ActiveRecord def transfer(project) @old_path = project.full_path @@ -132,6 +132,8 @@ module Projects end def rollback_folder_move + return if project.hashed_storage?(:repository) + move_repo_folder(@new_path, @old_path) move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki") end diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index e7e0141099e..b3cf27373cd 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -52,6 +52,10 @@ module Projects Projects::ForksCountService.new(project).refresh_cache end + # TODO: Remove this method once all LfsObjectsProject records are backfilled + # for forks. + # + # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info. def save_lfs_objects return unless @project.forked? diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb new file mode 100644 index 00000000000..6a39399c791 --- /dev/null +++ b/app/services/repositories/base_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Repositories::BaseService < BaseService + include Gitlab::ShellAdapter + + DELETED_FLAG = '+deleted' + + attr_reader :repository + + delegate :project, :disk_path, :full_path, to: :repository + delegate :repository_storage, to: :project + + def initialize(repository) + @repository = repository + end + + def repo_exists?(path) + gitlab_shell.repository_exists?(repository_storage, path + '.git') + end + + def mv_repository(from_path, to_path) + return true unless repo_exists?(from_path) + + gitlab_shell.mv_repository(repository_storage, from_path, to_path) + end + + # Build a path for removing repositories + # We use `+` because its not allowed by GitLab so user can not create + # project with name cookies+119+deleted and capture someone stalled repository + # + # gitlab/cookies.git -> gitlab/cookies+119+deleted.git + # + def removal_path + "#{disk_path}+#{project.id}#{DELETED_FLAG}" + end + + # If we get a Gitaly error, the repository may be corrupted. We can + # ignore these errors since we're going to trash the repositories + # anyway. + def ignore_git_errors(&block) + yield + rescue Gitlab::Git::CommandError => e + Gitlab::GitLogger.warn(class: self.class.name, project_id: project.id, disk_path: disk_path, message: e.to_s) + end + + def move_error(path) + error = %Q{Repository "#{path}" could not be moved} + + log_error(error) + error(error) + end +end diff --git a/app/services/repositories/destroy_rollback_service.rb b/app/services/repositories/destroy_rollback_service.rb new file mode 100644 index 00000000000..5ef4e11bf55 --- /dev/null +++ b/app/services/repositories/destroy_rollback_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Repositories::DestroyRollbackService < Repositories::BaseService + def execute + # There is a possibility project does not have repository or wiki + return success unless repo_exists?(removal_path) + + # Flush the cache for both repositories. + ignore_git_errors { repository.before_delete } + + if mv_repository(removal_path, disk_path) + log_info(%Q{Repository "#{removal_path}" moved to "#{disk_path}" for repository "#{full_path}"}) + + success + else + move_error(removal_path) + end + end +end diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb new file mode 100644 index 00000000000..374968f610e --- /dev/null +++ b/app/services/repositories/destroy_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Repositories::DestroyService < Repositories::BaseService + def execute + return success unless repository + return success unless repo_exists?(disk_path) + + # Flush the cache for both repositories. This has to be done _before_ + # removing the physical repositories as some expiration code depends on + # Git data (e.g. a list of branch names). + ignore_git_errors { repository.before_delete } + + if mv_repository(disk_path, removal_path) + log_info(%Q{Repository "#{disk_path}" moved to "#{removal_path}" for repository "#{full_path}"}) + + current_repository = repository + project.run_after_commit do + Repositories::ShellDestroyService.new(current_repository).execute + end + + log_info("Project \"#{project.full_path}\" was removed") + + success + else + move_error(disk_path) + end + end +end diff --git a/app/services/repositories/shell_destroy_service.rb b/app/services/repositories/shell_destroy_service.rb new file mode 100644 index 00000000000..2f5af10e24c --- /dev/null +++ b/app/services/repositories/shell_destroy_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Repositories::ShellDestroyService < Repositories::BaseService + REPO_REMOVAL_DELAY = 5.minutes.to_i + STALE_REMOVAL_DELAY = REPO_REMOVAL_DELAY * 2 + + def execute(delay = REPO_REMOVAL_DELAY) + return success unless repository + + GitlabShellWorker.perform_in(delay, + :remove_repository, + repository_storage, + removal_path) + end +end diff --git a/app/services/snippets/count_service.rb b/app/services/snippets/count_service.rb new file mode 100644 index 00000000000..9a3d33c75cf --- /dev/null +++ b/app/services/snippets/count_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Service for calculating visible Snippet counts via one query +# for the given user or project. +# +# Authorisation level checks will be included, ensuring the correct +# counts will be returned for the given user (if any). +# +# Basic usage: +# +# user = User.find(1) +# +# Snippets::CountService.new(user, author: user).execute +# #=> { +# are_public: 1, +# are_internal: 1, +# are_private: 1, +# all: 3 +# } +# +# Counts can be scoped to a project: +# +# user = User.find(1) +# project = Project.find(1) +# +# Snippets::CountService.new(user, project: project).execute +# #=> { +# are_public: 1, +# are_internal: 1, +# are_private: 0, +# all: 2 +# } +# +# Either a project or an author *must* be supplied. +module Snippets + class CountService + def initialize(current_user, author: nil, project: nil) + if !author && !project + raise( + ArgumentError, 'Must provide either an author or a project' + ) + end + + @snippets_finder = SnippetsFinder.new(current_user, author: author, project: project) + end + + def execute + counts = snippet_counts + return {} unless counts + + counts.slice( + :are_public, + :are_private, + :are_internal, + :are_public_or_internal, + :total + ) + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def snippet_counts + @snippets_finder.execute + .reorder(nil) + .select(" + count(case when snippets.visibility_level=#{Snippet::PUBLIC} and snippets.secret is FALSE then 1 else null end) as are_public, + count(case when snippets.visibility_level=#{Snippet::INTERNAL} then 1 else null end) as are_internal, + count(case when snippets.visibility_level=#{Snippet::PRIVATE} then 1 else null end) as are_private, + count(case when visibility_level=#{Snippet::PUBLIC} OR visibility_level=#{Snippet::INTERNAL} then 1 else null end) as are_public_or_internal, + count(*) as total + ") + .first + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 250e99c466a..7ded185a6f9 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -24,7 +24,9 @@ module Snippets spam_check(snippet, current_user) snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! + (snippet.save && snippet.store_mentions!).tap do |saved| + create_repository_for(snippet, current_user) if saved + end end if snippet_saved @@ -36,5 +38,11 @@ module Snippets snippet_error_response(snippet, 400) end end + + private + + def create_repository_for(snippet, user) + snippet.create_repository if Feature.enabled?(:version_snippets, user) + end end end diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb index f253817d94f..c1e87e74aa4 100644 --- a/app/services/snippets/destroy_service.rb +++ b/app/services/snippets/destroy_service.rb @@ -36,9 +36,7 @@ module Snippets attr_reader :snippet def user_can_delete_snippet? - return can?(current_user, :admin_project_snippet, snippet) if project - - can?(current_user, :admin_personal_snippet, snippet) + can?(current_user, :admin_snippet, snippet) end def service_response_error(message, http_status) diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 8d2c8cac148..c0c0aec2050 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -21,7 +21,7 @@ module Snippets spam_check(snippet, current_user) snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! + snippet.save end if snippet_saved diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb new file mode 100644 index 00000000000..7d16743b3ed --- /dev/null +++ b/app/services/spam/akismet_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Spam + class AkismetService + attr_accessor :text, :options + + def initialize(owner_name, owner_email, text, options = {}) + @owner_name = owner_name + @owner_email = owner_email + @text = text + @options = options + end + + def spam? + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + created_at: DateTime.now, + author: owner_name, + author_email: owner_email, + referrer: options[:referrer] + } + + begin + is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) + is_spam || is_blatant + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") # rubocop:disable Gitlab/RailsLogger + false + end + end + + def submit_ham + submit(:ham) + end + + def submit_spam + submit(:spam) + end + + private + + attr_accessor :owner_name, :owner_email + + def akismet_client + @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key, + Gitlab.config.gitlab.url) + end + + def akismet_enabled? + Gitlab::CurrentSettings.akismet_enabled + end + + def submit(type) + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner_name, + author_email: owner_email + } + + begin + akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") # rubocop:disable Gitlab/RailsLogger + false + end + end + end +end diff --git a/app/services/spam/ham_service.rb b/app/services/spam/ham_service.rb new file mode 100644 index 00000000000..d0f53eea90c --- /dev/null +++ b/app/services/spam/ham_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Spam + class HamService + include AkismetMethods + + attr_accessor :spam_log, :options + + def initialize(spam_log) + @spam_log = spam_log + @user = spam_log.user + @options = { + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + } + end + + def execute + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + alias_method :spammable, :spam_log + end +end diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_check_service.rb new file mode 100644 index 00000000000..d19ef03976f --- /dev/null +++ b/app/services/spam/spam_check_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Spam + class SpamCheckService + include AkismetMethods + + attr_accessor :spammable, :request, :options + attr_reader :spam_log + + def initialize(spammable:, request:) + @spammable = spammable + @request = request + @options = {} + + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] + else + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + end + + def execute(api: false, recaptcha_verified:, spam_log_id:, user_id:) + if recaptcha_verified + # If it's a request which is already verified through recaptcha, + # update the spam log accordingly. + SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id) + else + # Otherwise, it goes to Akismet for spam check. + # If so, it assigns spammable object as "spam" and creates a SpamLog record. + possible_spam = check(api) + spammable.spam = possible_spam unless spammable.allow_possible_spam? + spammable.spam_log = spam_log + end + end + + private + + def check(api) + return unless request + return unless check_for_spam? + return unless akismet.spam? + + create_spam_log(api) + true + end + + def check_for_spam? + spammable.check_for_spam? + end + + def create_spam_log(api) + @spam_log = SpamLog.create!( + { + user_id: spammable.author_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: options[:ip_address], + user_agent: options[:user_agent], + noteable_type: spammable.class.to_s, + via_api: api + } + ) + end + end +end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb deleted file mode 100644 index ba9b812a01c..00000000000 --- a/app/services/spam_service.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class SpamService - include AkismetMethods - - attr_accessor :spammable, :request, :options - attr_reader :spam_log - - def initialize(spammable:, request:) - @spammable = spammable - @request = request - @options = {} - - if @request - @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s - @options[:user_agent] = @request.env['HTTP_USER_AGENT'] - @options[:referrer] = @request.env['HTTP_REFERRER'] - else - @options[:ip_address] = @spammable.ip_address - @options[:user_agent] = @spammable.user_agent - end - end - - def when_recaptcha_verified(recaptcha_verified, api = false) - # In case it's a request which is already verified through recaptcha, yield - # block. - if recaptcha_verified - yield - else - # Otherwise, it goes to Akismet and check if it's a spam. If that's the - # case, it assigns spammable record as "spam" and create a SpamLog record. - possible_spam = check(api) - spammable.spam = possible_spam unless spammable.allow_possible_spam? - spammable.spam_log = spam_log - end - end - - private - - def check(api) - return false unless request && check_for_spam? - - return false unless akismet.spam? - - create_spam_log(api) - true - end - - def check_for_spam? - spammable.check_for_spam? - end - - def create_spam_log(api) - @spam_log = SpamLog.create!( - { - user_id: spammable_owner_id, - title: spammable.spam_title, - description: spammable.spam_description, - source_ip: options[:ip_address], - user_agent: options[:user_agent], - noteable_type: spammable.class.to_s, - via_api: api - } - ) - end -end diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 7927ab265c5..3265eb106eb 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -36,10 +36,12 @@ class SubmitUsagePingService private def store_metrics(response) - return unless response['conv_index'].present? + metrics = response['conv_index'] || response['dev_ops_score'] + + return unless metrics.present? DevOpsScore::Metric.create!( - response['conv_index'].slice(*METRICS) + metrics.slice(*METRICS) ) end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 38e0a7d34ad..8a0f44b4e93 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -99,6 +99,10 @@ module SystemNoteService ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent end + def close_after_error_tracking_resolve(issue, project, author) + ::SystemNotes::IssuablesService.new(noteable: issue, project: project, author: author).close_after_error_tracking_resolve + end + def change_status(noteable, project, author, status, source = nil) ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source) end @@ -237,23 +241,6 @@ module SystemNoteService def zoom_link_removed(issue, project, author) ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_removed end - - private - - def create_note(note_summary) - note = Note.create(note_summary.note.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? - - note - end - - def url_helpers - @url_helpers ||= Gitlab::Routing.url_helpers - end - - def content_tag(*args) - ActionController::Base.helpers.content_tag(*args) - end end SystemNoteService.prepend_if_ee('EE::SystemNoteService') diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 6fffd2ed4bf..d7787dac4b8 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -282,6 +282,12 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: action)) end + def close_after_error_tracking_resolve + body = _('resolved the corresponding error and closed the issue.') + + create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + end + private def cross_reference_note_content(gfm_reference) diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb index 1d17f0ded57..a26fc0f7d35 100644 --- a/app/services/system_notes/merge_requests_service.rb +++ b/app/services/system_notes/merge_requests_service.rb @@ -139,6 +139,17 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) end + + def picked_into_branch(branch_name, pick_commit) + link = url_helpers.project_tree_path(project, branch_name) + + body = "picked this merge request into branch [`#{branch_name}`](#{link}) with commit #{pick_commit}" + + summary = NoteSummary.new(noteable, project, author, body, action: 'cherry_pick') + summary.note[:commit_id] = pick_commit + + create_note(summary) + end end end diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index 21b52944800..21d0861ac3f 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -11,7 +11,7 @@ class UserProjectAccessChangedService if blocking AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args) else - AuthorizedProjectsWorker.bulk_perform_async(bulk_args) + AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext end end end diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb new file mode 100644 index 00000000000..9c393832d8f --- /dev/null +++ b/app/services/users/block_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Users + class BlockService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + if user.block + after_block_hook(user) + success + else + messages = user.errors.full_messages + error(messages.uniq.join('. ')) + end + end + + private + + def after_block_hook(user) + # overriden by EE module + end + end +end + +Users::BlockService.prepend_if_ee('EE::Users::BlockService') diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index 2ac6dfd90fa..ec8b3cea664 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -11,12 +11,19 @@ module Users def execute(skip_authorization: false) user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization) + reset_token = user.generate_reset_token if user.recently_sent_password_reset? - @reset_token = user.generate_reset_token if user.recently_sent_password_reset? - - notify_new_user(user, @reset_token) if user.save + after_create_hook(user, reset_token) if user.save user end + + private + + def after_create_hook(user, reset_token) + notify_new_user(user, reset_token) + end end end + +Users::CreateService.prepend_if_ee('EE::Users::CreateService') diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 643ebdc6839..ef79ee3d06e 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -56,12 +56,10 @@ module Users MigrateToGhostUserService.new(user).execute unless options[:hard_delete] - if Feature.enabled?(:destroy_user_associations_in_batches) - # Rails attempts to load all related records into memory before - # destroying: https://github.com/rails/rails/issues/22510 - # This ensures we delete records in batches. - user.destroy_dependent_associations_in_batches - end + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing user_data = user.destroy diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index e7667b0ca18..57209043e3b 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -53,7 +53,11 @@ module Users end def discard_read_only_attributes - discard_synced_attributes + if Feature.enabled?(:ldap_readonly_attributes, default_enabled: true) + params.reject! { |key, _| @user.read_only_attribute?(key.to_sym) } + else + discard_synced_attributes + end end def discard_synced_attributes diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 87edac36e33..514ba998d2c 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -2,12 +2,14 @@ class WebHookService class InternalErrorResponse + ERROR_MESSAGE = 'internal error' + attr_reader :body, :headers, :code def initialize @headers = Gitlab::HTTP::Response::Headers.new({}) @body = '' - @code = 'internal error' + @code = ERROR_MESSAGE end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index b79a5deb9c0..e4046e4b7e6 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -25,6 +25,10 @@ class AvatarUploader < GitlabUploader self.class.absolute_path(upload) end + def mounted_as + super || 'avatar' + end + private def dynamic_segment diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index b326b266017..0fc71d2e3f3 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -36,7 +36,7 @@ class FileUploader < GitlabUploader def self.base_dir(model, store = Store::LOCAL) decorated_model = model - decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE + decorated_model = Storage::Hashed.new(model) if store == Store::REMOTE model_path_segment(decorated_model) end @@ -57,7 +57,7 @@ class FileUploader < GitlabUploader # Returns a String without a trailing slash def self.model_path_segment(model) case model - when Storage::HashedProject then model.disk_path + when Storage::Hashed then model.disk_path else model.hashed_storage?(:attachments) ? model.disk_path : model.full_path end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 36bde629f9c..450ebb00b49 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -125,7 +125,7 @@ module ObjectStorage included do include AfterCommitQueue - after_save on: [:create, :update] do + after_save do background_upload(changed_mounts) end end diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 80a53dba2aa..aa377886edc 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -8,21 +8,21 @@ = f.label :gravatar_enabled, class: 'form-check-label' do = _('Gravatar enabled') .form-group - = f.label :default_projects_limit, class: 'label-bold' - = f.number_field :default_projects_limit, class: 'form-control' + = f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold' + = f.number_field :default_projects_limit, class: 'form-control', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' } .form-group = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold' - = f.number_field :max_attachment_size, class: 'form-control' + = f.number_field :max_attachment_size, class: 'form-control', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' } = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f .form-group = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field' + = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' } .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' - = f.number_field :session_expire_delay, class: 'form-control' - %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes') + = f.number_field :session_expire_delay, class: 'form-control', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.') = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index d716b52be05..9421585b70c 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -9,7 +9,7 @@ Enable version check .form-text.text-muted GitLab will inform you if a new version is available. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + = link_to 'Learn more', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check-core-only') about what information is shared with GitLab Inc. .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? @@ -21,12 +21,12 @@ - if can_be_configured %p.mb-2= _('To help improve GitLab and its user experience, GitLab will periodically collect usage information.') - - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping') + - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping-core-only') - usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path } %p.mb-2= s_('%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe } %button.btn.js-usage-ping-payload-trigger{ type: 'button' } - .js-spinner.d-none= icon('spinner spin') + .spinner.js-spinner.d-none .js-text.d-inline= _('Preview payload') %pre.usage-data.js-usage-ping-payload.js-syntax-highlight.code.highlight.mt-2.d-none{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } - else diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index b9f49fdc9de..94048060767 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -20,7 +20,7 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Session expiration, projects limit and attachment size.') + = _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.') .settings-content = render 'account_and_limit' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index ff40d7da892..0b747082de0 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -47,8 +47,7 @@ .settings-content = render 'performance_bar' -- if Feature.enabled?(:self_monitoring_project) - .js-self-monitoring-settings{ data: self_monitoring_project_data } +.js-self-monitoring-settings{ data: self_monitoring_project_data } %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) } .settings-header#usage-statistics diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 21e84016c66..8338401bea5 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -30,6 +30,14 @@ %span.form-text.text-muted Trusted applications are automatically authorized on GitLab OAuth flow. + = content_tag :div, class: 'form-group row' do + .col-sm-2.col-form-label.pt-0 + = f.label :confidential + .col-sm-10 + = f.check_box :confidential + %span.form-text.text-muted + = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.') + .form-group.row .col-sm-2.col-form-label.pt-0 = f.label :scopes diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 758d722cc63..c3861f335b8 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -12,6 +12,7 @@ %th Callback URL %th Clients %th Trusted + %th Confidential %th %th %tbody.oauth-applications @@ -21,6 +22,7 @@ %td= application.redirect_uri %td= @application_counts[application.id].to_i %td= application.trusted? ? 'Y': 'N' + %td= application.confidential? ? 'Y': 'N' %td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link' %td= render 'delete_form', application: application = paginate @applications, theme: 'gitlab' diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index aca9302aff7..146674a2fac 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -36,6 +36,12 @@ %td = @application.trusted? ? 'Y' : 'N' + %tr + %td + Confidential + %td + = @application.confidential? ? 'Y' : 'N' + = render "shared/tokens/scopes_list", token: @application .form-actions diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 33b56655206..9577a2a79df 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -5,15 +5,14 @@ = render_broadcast_message(@broadcast_message) - else Your message here -- if Feature.enabled?(:broadcast_notification_type) - .d-flex.justify-content-center - .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } - = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') - .js-broadcast-message-preview - - if @broadcast_message.message.present? - = render_broadcast_message(@broadcast_message) - - else - Your message here +.d-flex.justify-content-center + .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') + .js-broadcast-message-preview + - if @broadcast_message.message.present? + = render_broadcast_message(@broadcast_message) + - else + Your message here = form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) @@ -26,12 +25,11 @@ required: true, dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } - - if Feature.enabled?(:broadcast_notification_type) - .form-group.row - .col-sm-2.col-form-label - = f.label :broadcast_type, _('Type') - .col-sm-10 - = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type' + .form-group.row + .col-sm-2.col-form-label + = f.label :broadcast_type, _('Type') + .col-sm-10 + = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type' .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) } .col-sm-2.col-form-label = f.label :color, _("Background color") diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 6b138445675..0ec81d0eb04 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -42,7 +42,7 @@ .well-segment.admin-well.admin-well-features %h4 Features = feature_entry(_('Sign up'), - href: admin_application_settings_path(anchor: 'js-signup-settings'), + href: general_admin_application_settings_path(anchor: 'js-signup-settings'), enabled: allow_signup?) = feature_entry(_('LDAP'), @@ -50,11 +50,11 @@ doc_href: help_page_path('administration/auth/ldap')) = feature_entry(_('Gravatar'), - href: admin_application_settings_path(anchor: 'js-account-settings'), + href: general_admin_application_settings_path(anchor: 'js-account-settings'), enabled: gravatar_enabled?) = feature_entry(_('OmniAuth'), - href: admin_application_settings_path(anchor: 'js-signin-settings'), + href: general_admin_application_settings_path(anchor: 'js-signin-settings'), enabled: Gitlab::Auth.omniauth_enabled?, doc_href: help_page_path('integration/omniauth')) @@ -85,7 +85,7 @@ .float-right = version_status_badge %p - %a{ href: admin_application_settings_path } + %a{ href: general_admin_application_settings_path } GitLab %span.float-right = Gitlab::VERSION diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 3444e423235..855858ff929 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -10,6 +10,7 @@ = storage_counter(group.storage_size) = render_if_exists 'admin/namespace_plan_badge', namespace: group + = render_if_exists 'admin/groups/marked_for_deletion_badge', group: group %span = icon('bookmark') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 818d265c767..59e28a3b244 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -47,7 +47,7 @@ .filtered-search-box = dropdown_tag(_('Recent searches'), options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', - toggle_class: 'filtered-search-history-dropdown-toggle-button', + toggle_class: 'btn filtered-search-history-dropdown-toggle-button', dropdown_class: 'filtered-search-history-dropdown', content_class: 'filtered-search-history-dropdown-content' }) do .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 62be38e9dd2..f860b7a61a2 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -48,7 +48,7 @@ = project.full_name %td .float-right - = link_to 'Disable', [:admin, project.namespace.becomes(Namespace), project, runner_project], method: :delete, class: 'btn btn-danger btn-sm' + = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm' %table.table.unassigned-projects %thead @@ -70,10 +70,10 @@ = project.full_name %td .float-right - = form_for [:admin, project.namespace.becomes(Namespace), project, project.runner_projects.new] do |f| + = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f| = f.hidden_field :runner_id, value: @runner.id = f.submit 'Enable', class: 'btn btn-sm' - = paginate @projects, theme: "gitlab" + = paginate_without_count @projects .col-md-6 %h4 Recent jobs served by this Runner diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml new file mode 100644 index 00000000000..8c1c1d41caa --- /dev/null +++ b/app/views/admin/serverless/domains/_form.html.haml @@ -0,0 +1,68 @@ +- form_name = 'js-serverless-domain-settings' +- form_url = @domain.persisted? ? admin_serverless_domain_path(@domain.id, anchor: form_name) : admin_serverless_domains_path(anchor: form_name) +- show_certificate_card = @domain.persisted? && @domain.errors.blank? += form_for @domain, url: form_url, html: { class: 'fieldset-form' } do |f| + = form_errors(@domain) + + %fieldset + - if @domain.persisted? + - dns_record = "*.#{@domain.domain} CNAME #{Settings.pages.host}." + - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" + .form-group.row + .col-sm-6.position-relative + = f.label :domain, _('Domain'), class: 'label-bold' + = f.text_field :domain, class: 'form-control has-floating-status-badge', readonly: true + .status-badge.floating-status-badge + - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] + .badge{ class: status } + = text + = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification") + + .col-sm-6 + = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold' + .input-group + = text_field_tag :serverless_domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#serverless_domain_dns', class: 'btn-default input-group-text d-none d-sm-block') + + .col-sm-12.form-text.text-muted + = _("To access this domain create a new DNS record") + + .form-group + = f.label :serverless_domain_verification, _('Verification status'), class: 'label-bold' + .input-group + = text_field_tag :serverless_domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#serverless_domain_verification', class: 'btn-default d-none d-sm-block') + %p.form-text.text-muted + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) + = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } + + - else + .form-group + = f.label :domain, _('Domain'), class: 'label-bold' + = f.text_field :domain, class: 'form-control' + + - if show_certificate_card + .card.js-domain-cert-show + .card-header + = _('Certificate') + .d-flex.justify-content-between.align-items-center.p-3 + %span + = @domain.subject || _('missing') + %button.btn.btn-remove.btn-sm.js-domain-cert-replace-btn{ type: 'button' } + = _('Replace') + + .js-domain-cert-inputs{ class: ('hidden' if show_certificate_card) } + .form-group + = f.label :user_provided_certificate, _('Certificate (PEM)'), class: 'label-bold' + = f.text_area :user_provided_certificate, rows: 5, class: 'form-control', value: '' + %span.form-text.text-muted + = _("Upload a certificate for your domain with all intermediates") + .form-group + = f.label :user_provided_key, _('Key (PEM)'), class: 'label-bold' + = f.text_area :user_provided_key, rows: 5, class: 'form-control', value: '' + %span.form-text.text-muted + = _("Upload a private key for your certificate") + + = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted? diff --git a/app/views/admin/serverless/domains/index.html.haml b/app/views/admin/serverless/domains/index.html.haml new file mode 100644 index 00000000000..bd3c6bc6e04 --- /dev/null +++ b/app/views/admin/serverless/domains/index.html.haml @@ -0,0 +1,25 @@ +- breadcrumb_title _("Operations") +- page_title _("Operations") +- @content_class = "limit-container-width" unless fluid_layout + +-# normally expanded_by_default? is used here, but since this is the only panel +-# in this settings page, let's leave it always open by default +- expanded = true + +%section.settings.as-serverless-domain.no-animate#js-serverless-domain-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Serverless domain') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Set an instance-wide domain that will be available to all clusters when installing Knative.') + .settings-content + - if Gitlab.config.pages.enabled + = render 'form' + - else + .card + .card-header + = s_('GitLabPages|Domains') + .nothing-here-block + = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 77f7c478ffa..d823cd0412b 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -6,27 +6,25 @@ - if can?(current_user, :admin_cluster, @cluster) - unless @cluster.provided_by_user? - .append-bottom-20 - %label.append-bottom-10 + .sub-section.form-group + %h4 = @cluster.provider_label %p - provider_link = link_to(@cluster.provider_label, @cluster.provider_management_url, target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}').html_safe % { provider_link: provider_link } - = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| - - %h5 - = s_('ClusterIntegration|Cluster management project (alpha)') + .sub-section.form-group + = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| + %h4 + = s_('ClusterIntegration|Cluster management project (alpha)') - .form-group - .form-text.text-muted - = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', - placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) - .text-muted - = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe - = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' - .form-group - = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' + %p + = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) + .text-muted + = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe + = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' + = field.submit _('Save changes'), class: 'btn btn-success' - if @cluster.managed? .sub-section.form-group diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index 04afc38a056..9b6c0c20080 100644 --- a/app/views/clusters/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -4,6 +4,8 @@ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") .table-mobile-content = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } }) + - if cluster.status_name == :creating + .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") } - unless cluster.enabled? %span.badge.badge-danger Connection disabled .table-section.section-25 diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml index f9085b781fb..a85b005b2b4 100644 --- a/app/views/clusters/clusters/_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -31,7 +31,7 @@ = s_('ClusterIntegration|Alternatively') %code{ :class => "js-ingress-domain-snippet" } #{@cluster.application_ingress_external_ip}.nip.io = s_('ClusterIntegration| can be used instead of a custom domain.') - - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-external-endpoint') + - custom_domain_url = help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint') - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index 56d46580b9e..c10983a5405 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -1,10 +1,12 @@ - provider = local_assigns.fetch(:provider) +- is_current_provider = provider == params[:provider] - logo_path = local_assigns.fetch(:logo_path) - label = local_assigns.fetch(:label) - last = local_assigns.fetch(:last, false) -- classes = ['btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center', ('mr-3' unless last)] +- classes = ["btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center w-50 js-create-#{provider}-cluster-button"] +- conditional_classes = [('mr-3' unless last), ('active' if is_current_provider)] -= link_to clusterable.new_path(provider: provider), class: classes do += link_to clusterable.new_path(provider: provider), class: classes + conditional_classes do .svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64' %span = label diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 91925f5f96f..aee355bbf71 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -1,8 +1,8 @@ - gke_label = s_('ClusterIntegration|Google GKE') - eks_label = s_('ClusterIntegration|Amazon EKS') - create_cluster_label = s_('ClusterIntegration|Create cluster on') -.d-flex.flex-column - %h5.mb-3 +.d-flex.flex-column.p-3 + %h4.mb-3 = create_cluster_label .d-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index ab01569b8fd..e83bf61ab9b 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -66,11 +66,11 @@ - if Feature.enabled?(:create_cloud_run_clusters, clusterable, default_enabled: true) .form-group - = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), + = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run for Anthos'), label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' + = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), @@ -79,6 +79,6 @@ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' - .form-group + .form-group.js-gke-cluster-creation-submit-container = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index 629585d82cd..fae78fbb7f4 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,6 +1,7 @@ - breadcrumb_title _('Kubernetes') - page_title _('Kubernetes Cluster') - active_tab = local_assigns.fetch(:active_tab, 'create') +- provider = params[:provider] = javascript_include_tag 'https://apis.google.com/js/api.js' = render_gcp_signup_offer @@ -19,8 +20,12 @@ %span Add existing cluster .tab-content.gitlab-tab-content - .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render new_cluster_partial(provider: params[:provider]) + .tab-pane.p-0{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } + = render 'clusters/clusters/cloud_providers/cloud_provider_selector' + + - if ['aws', 'gcp'].include?(provider) + .p-3.border-top + = render "clusters/clusters/#{provider}/new" .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } = render 'clusters/clusters/user/header' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 4b295cd022d..e1f011a3225 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -29,12 +29,12 @@ pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), - ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), + ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'), ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'), - cloud_run_help_path: help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), + cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), manage_prometheus_path: manage_prometheus_path, cluster_id: @cluster.id } } diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 4359a2c3c2b..2db3e35250f 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -5,4 +5,5 @@ %i.fa.fa-rss .content_list -= spinner +.loading + .spinner.spinner-md diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index 4958cdc3745..d2fb4a3cd43 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -3,7 +3,7 @@ - if current_user && current_user.snippets.any? || @snippets.any? .page-title-controls - - if can?(current_user, :create_personal_snippet) + - if can?(current_user, :create_snippet) = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet") .top-area diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml index 5122164dbcb..ca201e626b8 100644 --- a/app/views/dashboard/projects/_projects.html.haml +++ b/app/views/dashboard/projects/_projects.html.haml @@ -1 +1 @@ -= render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user += render 'shared/projects/list', projects: @projects, user: current_user diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 44a9270971a..05214346496 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -1,11 +1,11 @@ - @hide_top_links = true - page_title "Snippets" - header_title "Snippets", dashboard_snippets_path -- button_path = new_snippet_path if can?(current_user, :create_personal_snippet) +- button_path = new_snippet_path if can?(current_user, :create_snippet) = render 'dashboard/snippets_head' - if current_user.snippets.exists? - = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } + = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts } - if current_user.snippets.exists? = render partial: 'shared/snippets/list', locals: { link_project: true } diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 5f85235e8fa..232dffa28b4 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -1,7 +1,16 @@ - page_title "Sign up" - if experiment_enabled?(:signup_flow) - = render 'devise/shared/experimental_separate_sign_up_flow_box' + .row + .col-lg-7 + %h1.mb-3.font-weight-bold.text-6.mt-0 + = _("Speed up your DevOps<br>with GitLab").html_safe + %p.text-3 + = _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.") + .col-lg-5.order-12 + .text-center.mb-3 + %h2.font-weight-bold.gl-font-size-20= _('Register for GitLab') + = render 'devise/shared/experimental_separate_sign_up_flow_box' + = render 'devise/shared/sign_in_link' - else = render 'devise/shared/signup_box' - -= render 'devise/shared/sign_in_link' + = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml index 4832861445b..7bc3042c94d 100644 --- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -1,4 +1,3 @@ -- content_for(:page_title, _('Register for GitLab')) - max_first_name_length = max_last_name_length = 127 - max_username_length = 255 .signup-box.p-3.mb-2 @@ -41,3 +40,5 @@ = recaptcha_tags .submit-container.mt-3 = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' } + - if omniauth_enabled? && button_based_providers_enabled? + = render 'devise/shared/experimental_separate_sign_up_flow_omniauth_box' diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml new file mode 100644 index 00000000000..d9143d90430 --- /dev/null +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml @@ -0,0 +1,13 @@ +.omniauth-divider.d-flex.align-items-center.text-center + = _("or") +%label.label-bold.d-block + = _("Create an account using:") +- providers = enabled_button_based_providers +.d-flex.justify-content-between.flex-wrap + - providers.each do |provider| + - has_icon = provider_has_icon?(provider) + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do + - if has_icon + = provider_image_tag(provider) + %span.ml-2 + = label_for_provider(provider) diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 78904f550c7..79abe31a056 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -15,6 +15,12 @@ %span.form-text.text-muted = _('Use <code>%{native_redirect_uri}</code> for local tests').html_safe % { native_redirect_uri: Doorkeeper.configuration.native_redirect_uri } + .form-group.form-check + = f.check_box :confidential, class: 'form-check-input' + = f.label :confidential, class: 'label-bold form-check-label' + %span.form-text.text-muted + = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.') + .form-group = f.label :scopes, class: 'label-bold' = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 8a1b7500abf..7b29269dbb1 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -34,6 +34,12 @@ %div %span.monospace= uri + %tr + %td + = _('Confidential') + %td + = @application.confidential? ? _('Yes') : _('No') + = render "shared/tokens/scopes_list", token: @application .form-actions diff --git a/app/views/explore/projects/_nav.html.haml b/app/views/explore/projects/_nav.html.haml index bf65c19b720..65b7d055843 100644 --- a/app/views/explore/projects/_nav.html.haml +++ b/app/views/explore/projects/_nav.html.haml @@ -1,14 +1,14 @@ .top-area %ul.nav-links.nav.nav-tabs - = nav_link(page: [trending_explore_projects_path, explore_root_path]) do - = link_to trending_explore_projects_path do - = _('Trending') + = nav_link(page: [explore_projects_path, explore_root_path]) do + = link_to explore_projects_path do + = _('All') = nav_link(page: starred_explore_projects_path) do = link_to starred_explore_projects_path do = _('Most stars') - = nav_link(page: explore_projects_path) do - = link_to explore_projects_path do - = _('All') + = nav_link(page: trending_explore_projects_path) do + = link_to trending_explore_projects_path do + = _('Trending') .nav-controls - unless current_user diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml index d819c4ea554..35b32662b8a 100644 --- a/app/views/explore/projects/_projects.html.haml +++ b/app/views/explore/projects/_projects.html.haml @@ -1,2 +1,2 @@ - is_explore_page = defined?(explore_page) && explore_page -= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true) += render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml new file mode 100644 index 00000000000..57114dd0752 --- /dev/null +++ b/app/views/explore/projects/page_out_of_bounds.html.haml @@ -0,0 +1,21 @@ +- @hide_top_links = true +- page_title _("Projects") +- header_title _("Projects"), dashboard_projects_path + += render_dashboard_gold_trial(current_user) + +- if current_user + = render 'dashboard/projects_head', project_tab_filter: :explore +- else + = render 'explore/head' + += render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user + +.nothing-here-block + .svg-content + = image_tag 'illustrations/profile-page/personal-project.svg', size: '75' + .text-content + %h5= _("Maximum page reached") + %p= _("Sorry, you have exceeded the maximum browsable page number. Please use the API to explore further.") + + = link_to _("Back to page %{number}") % { number: @max_page_number }, request.params.merge(page: @max_page_number), class: 'btn btn-inverted' diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml index 2531993a095..07394eec107 100644 --- a/app/views/groups/_create_chat_team.html.haml +++ b/app/views/groups/_create_chat_team.html.haml @@ -6,7 +6,7 @@ Mattermost .col-sm-10 .form-check.js-toggle-container - .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false) + .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: false }, true, false) = f.label :create_chat_team, class: 'form-check-label' do = _('Create a Mattermost team for this group') %br diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index e50d2b8e994..6772ee94d46 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -30,7 +30,7 @@ .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } - = sprite_icon("arrow-down", css_class: "icon dropdown-btn-icon") + = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon") %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } .menu-item diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index e85b0713230..b82910df5d5 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -3,10 +3,21 @@ %section .row.registry-placeholder.prepend-bottom-10 .col-12 - #js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json), - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => "", - is_group_page: true, - character_error: @character_error.to_s } } + - if Feature.enabled?(:vue_container_registry_explorer) + #js-container-registry{ data: { endpoint: group_container_registries_path(@group), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "registry_host_url_with_port" => escape_once(registry_config.host_port), + is_group_page: true, + character_error: @character_error.to_s } } + - else + #js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => "", + is_group_page: true, + character_error: @character_error.to_s } } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 307309c6ca3..2734ab538a0 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -39,12 +39,5 @@ %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' -.sub-section - %h4.danger-title= _('Remove group') - = form_tag(@group, method: :delete) do - %p - = _('Removing group will cause all child projects and resources to be removed.') - %br - %strong= _('Removed group can not be restored!') - - = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(@group) } += render 'groups/settings/remove', group: @group += render_if_exists 'groups/settings/restore', group: @group diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml new file mode 100644 index 00000000000..31e2bac70be --- /dev/null +++ b/app/views/groups/settings/_permanent_deletion.html.haml @@ -0,0 +1,9 @@ +.sub-section + %h4.danger-title= _('Remove group') + = form_tag(group, method: :delete) do + %p + = _('Removing group will cause all child projects and resources to be removed.') + %br + %strong= _('Removed group can not be restored!') + + = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) } diff --git a/app/views/groups/settings/_remove.html.haml b/app/views/groups/settings/_remove.html.haml new file mode 100644 index 00000000000..a617467019a --- /dev/null +++ b/app/views/groups/settings/_remove.html.haml @@ -0,0 +1,5 @@ +- if group.adjourned_deletion? + = render_if_exists 'groups/settings/adjourned_deletion', group: group +- else + = render 'groups/settings/permanent_deletion', group: group + diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 457d05b4a97..4916c4651dd 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -9,6 +9,8 @@ = render 'groups/home_panel' + = render_if_exists 'groups/self_or_ancestor_marked_for_deletion_notice', group: @group + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 5f8f2333e40..4b9304cfdb9 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -6,6 +6,7 @@ = _('Keyboard Shortcuts') %small = link_to _('(Show all)'), '#', class: 'js-more-help-button' + .js-toggle-shortcuts %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × .modal-body diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index b8a421ac9d3..7e0b444e5d7 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -283,7 +283,7 @@ Dropdown option .dropdown-footer %strong Tip: - If an author is not a member of this project, you can still filter by his name while using the search field. + If an author is not a member of this project, you can still filter by their name while using the search field. .dropdown.inline %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } Dropdown loading @@ -322,7 +322,7 @@ Dropdown option .dropdown-footer %strong Tip: - If an author is not a member of this project, you can still filter by his name while using the search field. + If an author is not a member of this project, you can still filter by their name while using the search field. .dropdown-loading = icon('spinner spin') diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml index 4d13d4f2869..35059229a55 100644 --- a/app/views/import/shared/_new_project_form.html.haml +++ b/app/views/import/shared/_new_project_form.html.haml @@ -10,7 +10,7 @@ .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } .input-group-text = root_url - = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 + = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated', tabindex: 1 - else .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } .input-group-text.border-0 diff --git a/app/views/instance_statistics/cohorts/_usage_ping.html.haml b/app/views/instance_statistics/cohorts/_usage_ping.html.haml deleted file mode 100644 index 3dda386fcf7..00000000000 --- a/app/views/instance_statistics/cohorts/_usage_ping.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%h2#usage-ping Usage ping - -.bs-callout.clearfix - %p - User cohorts are shown because the usage ping is enabled. The data sent with - this is shown below. To disable this, visit - = succeed '.' do - = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics') - -%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } diff --git a/app/views/instance_statistics/cohorts/index.html.haml b/app/views/instance_statistics/cohorts/index.html.haml index c438566cb05..5333f8b7a1f 100644 --- a/app/views/instance_statistics/cohorts/index.html.haml +++ b/app/views/instance_statistics/cohorts/index.html.haml @@ -9,6 +9,6 @@ - usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path } = s_('User Cohorts are only shown when the %{usage_ping_link_start}usage ping%{usage_ping_link_end} is enabled.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe } - if current_user.admin? - - application_settings_path = admin_application_settings_path(anchor: 'usage-statistics') + - application_settings_path = metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings') - application_settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: application_settings_path } = s_('To enable it and see User Cohorts, visit %{application_settings_link_start}application settings%{application_settings_link_end}.').html_safe % { application_settings_link_start: application_settings_link_start, application_settings_link_end: '</a>'.html_safe } diff --git a/app/views/kaminari/gitlab/_first_page.html.haml b/app/views/kaminari/gitlab/_first_page.html.haml deleted file mode 100644 index 3b7d4a1c578..00000000000 --- a/app/views/kaminari/gitlab/_first_page.html.haml +++ /dev/null @@ -1,9 +0,0 @@ --# Link to the "First" page --# available local variables --# url: url to the first page --# current_page: a page object for the currently displayed page --# total_pages: total number of pages --# per_page: number of items to fetch per page --# remote: data-remote -%li.page-item.js-first-button - = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, remote: remote, class: 'page-link' diff --git a/app/views/kaminari/gitlab/_last_page.html.haml b/app/views/kaminari/gitlab/_last_page.html.haml deleted file mode 100644 index 7836e17f877..00000000000 --- a/app/views/kaminari/gitlab/_last_page.html.haml +++ /dev/null @@ -1,9 +0,0 @@ --# Link to the "Last" page --# available local variables --# url: url to the last page --# current_page: a page object for the currently displayed page --# total_pages: total number of pages --# per_page: number of items to fetch per page --# remote: data-remote -%li.page-item.js-last-button - = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {remote: remote, class: 'page-link'} diff --git a/app/views/kaminari/gitlab/_next_page.html.haml b/app/views/kaminari/gitlab/_next_page.html.haml index a7fa1a21a6c..9572dd91330 100644 --- a/app/views/kaminari/gitlab/_next_page.html.haml +++ b/app/views/kaminari/gitlab/_next_page.html.haml @@ -9,4 +9,6 @@ - page_url = current_page.last? ? '#' : url %li.page-item.js-next-button{ class: ('disabled' if current_page.last?) } - = link_to raw(t 'views.pagination.next'), page_url, rel: 'next', remote: remote, class: 'page-link' + = link_to page_url, rel: 'next', remote: remote, class: 'page-link' do + = s_('Pagination|Next') + = sprite_icon('angle-right', size: 8) diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml index d0dc1784540..33e00256100 100644 --- a/app/views/kaminari/gitlab/_page.html.haml +++ b/app/views/kaminari/gitlab/_page.html.haml @@ -6,5 +6,9 @@ -# total_pages: total number of pages -# per_page: number of items to fetch per page -# remote: data-remote -%li.page-item.js-pagination-page{ class: [active_when(page.current?), ('sibling' if page.next? || page.prev?), ('d-none d-md-block' if !page.current?) ] } +%li.page-item.js-pagination-page{ class: [active_when(page.current?), + ('sibling' if page.next? || page.prev?), + ('js-first-button' if page.first?), + ('js-last-button' if page.last?), + ('d-none d-md-block' if !page.current?) ] } = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: 'page-link' } diff --git a/app/views/kaminari/gitlab/_paginator.html.haml b/app/views/kaminari/gitlab/_paginator.html.haml index ac9e274dbc7..1b2edc0ad22 100644 --- a/app/views/kaminari/gitlab/_paginator.html.haml +++ b/app/views/kaminari/gitlab/_paginator.html.haml @@ -8,14 +8,10 @@ = paginator.render do .gl-pagination.prepend-top-default %ul.pagination.justify-content-center - - unless current_page.first? - = first_page_tag unless total_pages < 5 # As kaminari will always show the first 5 pages = prev_page_tag - each_page do |page| - - if page.left_outer? || page.right_outer? || page.inside_window? + - if page.left_outer? || page.right_outer? || page.inside_window? || page.first? || page.last? = page_tag page - elsif !page.was_truncated? = gap_tag = next_page_tag - - unless current_page.last? - = last_page_tag unless total_pages < 5 diff --git a/app/views/kaminari/gitlab/_prev_page.html.haml b/app/views/kaminari/gitlab/_prev_page.html.haml index 12b0e106a62..4ba7ab6488a 100644 --- a/app/views/kaminari/gitlab/_prev_page.html.haml +++ b/app/views/kaminari/gitlab/_prev_page.html.haml @@ -9,4 +9,6 @@ - page_url = current_page.first? ? '#' : url %li.page-item.js-previous-button{ class: ('disabled' if current_page.first?) } - = link_to raw(t 'views.pagination.previous'), page_url, rel: 'prev', remote: remote, class: 'page-link' + = link_to page_url, rel: 'prev', remote: remote, class: 'page-link' do + = sprite_icon('angle-left', size: 8) + = s_('Pagination|Prev') diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml index f780400ebcb..d13f6ca5fa8 100644 --- a/app/views/kaminari/gitlab/_without_count.html.haml +++ b/app/views/kaminari/gitlab/_without_count.html.haml @@ -2,7 +2,11 @@ %ul.pagination.justify-content-center - if previous_path %li.page-item.prev - = link_to(t('views.pagination.previous'), previous_path, rel: 'prev', class: 'page-link') + = link_to previous_path, rel: 'prev', class: 'page-link' do + = sprite_icon('angle-left', size: 8) + = s_('Pagination|Prev') - if next_path %li.page-item.next - = link_to(t('views.pagination.next'), next_path, rel: 'next', class: 'page-link') + = link_to next_path, rel: 'next', class: 'page-link' do + = s_('Pagination|Next') + = sprite_icon('angle-right', size: 8) diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml index 9d7ad249ac8..4c4fc6411b8 100644 --- a/app/views/layouts/_broadcast.html.haml +++ b/app/views/layouts/_broadcast.html.haml @@ -1,4 +1,3 @@ - current_broadcast_banner_messages.each do |message| = broadcast_message(message) -- if Feature.enabled?(:broadcast_notification_type) - = broadcast_message(current_broadcast_notification_message) += broadcast_message(current_broadcast_notification_message) diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 443a73f5cce..2b2ffd6abeb 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -11,6 +11,7 @@ = render "layouts/nav/classification_level_banner" = yield :flash_message = render "shared/ping_consent" + = render_account_recovery_regular_check - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" .d-flex diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 7af190f5a0b..eb58115451d 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -4,7 +4,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data } + %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} #{client_class_list}", data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render 'peek/bar' diff --git a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml index 2f05717fc0e..fddfe14e05f 100644 --- a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml +++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml @@ -1,22 +1,15 @@ !!! 5 %html.devise-layout-html.navless{ class: system_message_class } = render "layouts/head" - %body.ui-indigo.signup-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } - = header_message + %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + = render "layouts/header/logo_with_title" = render "layouts/init_client_detection_flags" .page-wrap - .container.signup-box-container.navless-container.mt-0 + .container.signup-box-container.navless-container = render "layouts/broadcast" .content = render "layouts/flash" - .row.mb-3 - .col-sm-8.offset-sm-2.col-md-6.offset-md-3.new-session-forms-container - = render_if_exists 'layouts/devise_help_text' - .text-center.signup-heading.mt-3.mb-3 - = image_tag(image_url('logo.svg'), class: 'gitlab-logo', alt: 'GitLab Logo') - - if content_for?(:page_title) - %h2= yield :page_title - = yield + = yield %hr.footer-fixed .footer-container .container diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index 91a7777514c..8d0775f6f27 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_class } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class} fullscreen-layout", data: { page: body_data_page } } + %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message = render partial: "layouts/header/default", locals: { project: @project, group: @group } diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 93854c212df..a003d6f8903 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -4,6 +4,10 @@ = link_to _("Help"), help_path %li = link_to _("Support"), support_url + %li + %button.js-shortcuts-modal-trigger{ type: "button" } + = _("Keyboard shortcuts") + %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe = render_if_exists "shared/learn_gitlab_menu_item" %li.divider %li diff --git a/app/views/layouts/header/_logo_with_title.html.haml b/app/views/layouts/header/_logo_with_title.html.haml new file mode 100644 index 00000000000..1ea6168fc9a --- /dev/null +++ b/app/views/layouts/header/_logo_with_title.html.haml @@ -0,0 +1,4 @@ +%header.navbar.fixed-top.navbar-gitlab.justify-content-center + = render 'shared/logo.svg' + %span.logo-text.d-none.d-lg-block.prepend-left-8.pt-1 + = render 'shared/logo_type.svg' diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 30109621515..3cbfb24a868 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -21,7 +21,7 @@ - if @project&.persisted? - create_project_issue = show_new_issue_link?(@project) - merge_project = merge_request_source_project_for_project(@project) - - create_project_snippet = can?(current_user, :create_project_snippet, @project) + - create_project_snippet = can?(current_user, :create_snippet, @project) - if create_project_issue || merge_project || create_project_snippet %li.dropdown-bold-header @@ -38,5 +38,5 @@ %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link' - if current_user.can_create_group? %li= link_to _('New group'), new_group_path - - if current_user.can?(:create_personal_snippet) + - if current_user.can?(:create_snippet) %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link' diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 379ba976040..2efb304b397 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -76,7 +76,7 @@ - if Feature.enabled?(:user_mode_in_session) - if header_link?(:admin_mode) = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do - = link_to destroy_admin_session_path, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = link_to destroy_admin_session_path, method: :post, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = sprite_icon('lock-open', size: 18) - elsif current_user.admin? = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 71fef5df5bc..9f70124ba0d 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -221,7 +221,7 @@ = _('Appearance') = nav_link(controller: :application_settings) do - = link_to admin_application_settings_path do + = link_to general_admin_application_settings_path do .nav-icon-container = sprite_icon('settings') %span.nav-item-name.qa-admin-settings-item @@ -229,11 +229,11 @@ %ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do - = link_to admin_application_settings_path do + = link_to general_admin_application_settings_path do %strong.fly-out-top-item-name = _('Settings') %li.divider.fly-out-top-item - = nav_link(path: 'application_settings#show') do + = nav_link(path: 'application_settings#general') do = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') @@ -254,6 +254,11 @@ = link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do %span = _('CI/CD') + - if Feature.enabled?(:serverless_domain) + = nav_link(path: 'application_settings#operations') do + = link_to admin_serverless_domains_path, title: _('Operations') do + %span + = _('Operations') = nav_link(path: 'application_settings#reporting') do = link_to reporting_admin_application_settings_path, title: _('Reporting') do %span diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml new file mode 100644 index 00000000000..e87cf92374a --- /dev/null +++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml @@ -0,0 +1,16 @@ +- navbar_links = links.sort_by(&:title) +- all_paths = navbar_links.map(&:path) + +- if navbar_links.any? + = nav_link(path: all_paths) do + = link_to navbar_links.first.link do + .nav-icon-container + = sprite_icon('chart') + %span.nav-item-name{ data: { qa_selector: 'analytics_link' } } + = _('Analytics') + + %ul.sidebar-sub-level-items{ data: { qa_selector: 'analytics_sidebar_submenu' } } + - navbar_links.each do |menu_item| + = nav_link(path: menu_item.path) do + = link_to(menu_item.link, menu_item.link_to_options) do + %span= menu_item.title diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 88bb0a97487..c00c48b623c 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,3 +1,4 @@ +- should_display_analytics_pages_in_sidebar = Feature.enabled?(:analytics_pages_under_group_analytics_sidebar, @group, default_enabled: true) - issues_count = group_issues_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened') @@ -11,7 +12,9 @@ = @group.name %ul.sidebar-top-level-items.qa-group-sidebar - if group_sidebar_link?(:overview) - = nav_link(path: group_overview_nav_link_paths, html_options: { class: 'home' }) do + - paths = group_overview_nav_link_paths + - paths << 'contribution_analytics#show' unless should_display_analytics_pages_in_sidebar + = nav_link(path: paths, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do = link_to group_path(@group) do .nav-icon-container = sprite_icon('home') @@ -42,18 +45,19 @@ %span = _('Activity') - - if group_sidebar_link?(:contribution_analytics) - = nav_link(path: 'analytics#show') do - = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do - %span - = _('Contribution Analytics') + - unless should_display_analytics_pages_in_sidebar + - if group_sidebar_link?(:contribution_analytics) + = nav_link(path: 'contribution_analytics#show') do + = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do + %span + = _('Contribution Analytics') - = render_if_exists 'layouts/nav/group_insights_link' + = render_if_exists 'layouts/nav/group_insights_link' = render_if_exists "layouts/nav/ee/epic_link", group: @group - if group_sidebar_link?(:issues) - = nav_link(path: group_issues_sub_menu_items) do + = nav_link(path: group_issues_sub_menu_items, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('issues_analytics#show') }) do = link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' } do .nav-icon-container = sprite_icon('issues') @@ -80,7 +84,8 @@ %span = boards_link_text - = render_if_exists 'layouts/nav/issues_analytics_link' + - unless should_display_analytics_pages_in_sidebar + = render_if_exists 'layouts/nav/issues_analytics_link' - if group_sidebar_link?(:labels) = nav_link(path: 'labels#index') do @@ -126,6 +131,8 @@ = render_if_exists 'groups/sidebar/packages' + = render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user) + - if group_sidebar_link?(:group_members) = nav_link(path: 'group_members#index') do = link_to group_group_members_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 3464cc1ea07..b9324f0596c 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1,3 +1,5 @@ +- should_display_analytics_pages_in_sidebar = Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, @project, default_enabled: true) + .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll - can_edit = can?(current_user, :admin_project, @project) @@ -7,8 +9,10 @@ = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40) .sidebar-context-title = @project.name - %ul.sidebar-top-level-items - = nav_link(path: sidebar_projects_paths, html_options: { class: 'home' }) do + %ul.sidebar-top-level-items.qa-project-sidebar + - paths = sidebar_projects_paths + - paths << 'cycle_analytics#show' unless should_display_analytics_pages_in_sidebar + = nav_link(path: paths, html_options: { class: 'home' }) do = link_to project_path(@project), class: 'shortcuts-project rspec-project-link', data: { qa_selector: 'project_link' } do .nav-icon-container = sprite_icon('home') @@ -34,15 +38,18 @@ = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do %span= _('Releases') - - if can?(current_user, :read_cycle_analytics, @project) - = nav_link(path: 'cycle_analytics#show') do - = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do - %span= _('Cycle Analytics') - = render_if_exists 'layouts/nav/project_insights_link' + - unless should_display_analytics_pages_in_sidebar + - if can?(current_user, :read_cycle_analytics, @project) + = nav_link(path: 'cycle_analytics#show') do + = link_to project_cycle_analytics_path(@project), title: _('Value Stream Analytics'), class: 'shortcuts-project-cycle-analytics' do + %span= _('Value Stream Analytics') + + = render_if_exists 'layouts/nav/project_insights_link' + - if project_nav_tab? :files - = nav_link(controller: sidebar_repository_paths) do + = nav_link(controller: sidebar_repository_paths, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('projects/graphs#charts') }) do = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do .nav-icon-container = sprite_icon('doc-text') @@ -83,9 +90,10 @@ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do = _('Compare') - = nav_link(path: 'graphs#charts') do - = link_to charts_project_graph_path(@project, current_ref) do - = _('Charts') + - unless should_display_analytics_pages_in_sidebar + = nav_link(path: 'graphs#charts') do + = link_to charts_project_graph_path(@project, current_ref) do + = _('Charts') = render_if_exists 'projects/sidebar/repository_locked_files' @@ -170,7 +178,7 @@ = number_with_delimiter(@project.open_merge_requests_count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts]) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], unless: -> { should_display_analytics_pages_in_sidebar && current_path?('projects/pipelines#charts') }) do = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do .nav-icon-container = sprite_icon('rocket') @@ -201,13 +209,13 @@ %span = _('Artifacts') - - if project_nav_tab? :pipelines + - if project_nav_tab?(:pipelines) = nav_link(controller: :pipeline_schedules) do = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do %span = _('Schedules') - - if @project.feature_available?(:builds, current_user) && !@project.empty_repo? + - if !should_display_analytics_pages_in_sidebar && @project.feature_available?(:builds, current_user) && !@project.empty_repo? = nav_link(path: 'pipelines#charts') do = link_to charts_project_pipelines_path(@project), title: _('Charts'), class: 'shortcuts-pipelines-charts' do %span @@ -232,7 +240,7 @@ - if project_nav_tab? :environments = nav_link(controller: :environments, action: [:metrics, :metrics_redirect]) do - = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics' do + = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do %span = _('Metrics') @@ -290,7 +298,7 @@ = render_if_exists 'layouts/nav/sidebar/project_packages_link' - = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific + = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user) - if project_nav_tab? :wiki - wiki_url = project_wiki_path(@project, :home) @@ -410,11 +418,12 @@ = link_to project_network_path(@project, current_ref), title: _('Network'), class: 'shortcuts-network' do = _('Graph') - -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs") - - unless @project.empty_repo? - %li.hidden - = link_to charts_project_graph_path(@project, current_ref), title: _('Charts'), class: 'shortcuts-repository-charts' do - = _('Charts') + - unless should_display_analytics_pages_in_sidebar + -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs") + - unless @project.empty_repo? + %li.hidden + = link_to charts_project_graph_path(@project, current_ref), title: _('Charts'), class: 'shortcuts-repository-charts' do + = _('Charts') -# Shortcut to Issues > New Issue - if project_nav_tab?(:issues) diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml index 7c563bb016c..1711c34a842 100644 --- a/app/views/notify/_failed_builds.html.haml +++ b/app/views/notify/_failed_builds.html.haml @@ -27,6 +27,6 @@ - if build.has_trace? %td{ colspan: "2", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 0 16px;" } %pre{ style: "font-family: Monaco,'Lucida Console','Courier New',Courier,monospace; background-color: #fafafa; border-radius: 4px; overflow: hidden; white-space: pre-wrap; word-break: break-all; font-size:13px; line-height: 1.4; padding: 16px 8px; color: #333333; margin: 0;" } - = build.trace.html(last_lines: 10).html_safe + = build.trace.html(last_lines: 30).html_safe - else %td{ colspan: "2" } diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb index bf863952478..91092060e74 100644 --- a/app/views/notify/autodevops_disabled_email.text.erb +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -15,6 +15,6 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. Stage: <%= build.stage %> Name: <%= build.name %> <% if build.has_trace? -%> - Trace: <%= build.trace.raw(last_lines: 10) %> + Trace: <%= build.trace.raw(last_lines: 30) %> <% end -%> <% end -%> diff --git a/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml new file mode 100644 index 00000000000..b6563b185b3 --- /dev/null +++ b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -0,0 +1 @@ += build.name diff --git a/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb new file mode 100644 index 00000000000..af8924bad57 --- /dev/null +++ b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb @@ -0,0 +1 @@ +Job #<%= build.id %> diff --git a/app/views/notify/note_project_snippet_email.html.haml b/app/views/notify/note_project_snippet_email.html.haml deleted file mode 100644 index 5e69f01a486..00000000000 --- a/app/views/notify/note_project_snippet_email.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'note_email' diff --git a/app/views/notify/note_project_snippet_email.text.erb b/app/views/notify/note_project_snippet_email.text.erb deleted file mode 100644 index 413d9e6e9ac..00000000000 --- a/app/views/notify/note_project_snippet_email.text.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render 'note_email' %> diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml index 5e69f01a486..5e69f01a486 100644 --- a/app/views/notify/note_personal_snippet_email.html.haml +++ b/app/views/notify/note_snippet_email.html.haml diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb index 413d9e6e9ac..413d9e6e9ac 100644 --- a/app/views/notify/note_personal_snippet_email.text.erb +++ b/app/views/notify/note_snippet_email.text.erb diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 9cd479ef1e6..41b26842dbc 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. Stage: <%= build.stage %> Name: <%= build.name %> <% if build.has_trace? -%> -Trace: <%= build.trace.raw(last_lines: 10) %> +Trace: <%= build.trace.raw(last_lines: 30) %> <% end -%> <% end -%> diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 93acd6f550b..12d42ce9892 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -69,6 +69,15 @@ = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do = s_('Preferences|Show whitespace changes in diffs') + .form-group + = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' + = f.number_field :tab_width, + class: 'form-control', + min: Gitlab::TabWidth::MIN, + max: Gitlab::TabWidth::MAX, + required: true + .form-text.text-muted + = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX } .col-sm-12 %hr diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb index 8966dd3fd86..8397acbf1b3 100644 --- a/app/views/profiles/preferences/update.js.erb +++ b/app/views/profiles/preferences/update.js.erb @@ -12,5 +12,9 @@ if ('<%= current_user.layout %>' === 'fluid') { // Re-enable the "Save" button $('input[type=submit]').enable() -// Show the notice flash message -new Flash('<%= flash.discard(:notice) %>', 'notice') +// Show flash messages +<% if flash.notice %> + new Flash('<%= flash.discard(:notice) %>', 'notice') +<% elsif flash.alert %> + new Flash('<%= flash.discard(:alert) %>', 'alert') +<% end %> diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index daedc52f298..d9887cb470a 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -10,7 +10,7 @@ = 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.prepend-top-8.append-bottom-5.qa-project-name + %h1.home-panel-title.prepend-top-8.append-bottom-5{ data: { qa_selector: 'project_name_content' } } = @project.name %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) @@ -49,13 +49,6 @@ = render 'projects/buttons/star' = render 'projects/buttons/fork' - - if can?(current_user, :download_code, @project) - .project-clone-holder.d-inline-flex.d-md-none.btn-block - = render "shared/mobile_clone_panel" - - .project-clone-holder.d-none.d-md-inline-flex - = render "projects/buttons/clone" - - if can?(current_user, :download_code, @project) %nav.project-stats .nav-links.quick-links @@ -77,7 +70,7 @@ - source = visible_fork_source(@project) - if source #{ s_('ForkedFromProjectPath|Forked from') } - = link_to source.full_name, project_path(source) + = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' } - else = s_('ForkedFromProjectPath|Forked from an inaccessible project') diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index c502b392384..744aef3cad4 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -2,6 +2,7 @@ - current_text ||= nil - supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true) - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) +- qa_selector = local_assigns.fetch(:qa_selector, '') .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f @@ -10,7 +11,8 @@ placeholder: placeholder, dir: 'auto', data: { supports_quick_actions: supports_quick_actions, - supports_autocomplete: supports_autocomplete } + supports_autocomplete: supports_autocomplete, + qa_selector: qa_selector } - else = text_area_tag attr, current_text, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index cf273aab108..91d1fc06a41 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -9,6 +9,8 @@ = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder + - if native_code_navigation_enabled?(@project) + #js-code-navigation{ data: { commit_id: blob.commit_id, blob_path: blob.path, project_path: @project.full_path } } %article.file-holder = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 77245114772..76a9d3df5d7 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -2,18 +2,16 @@ .js-file-title.file-title-flex-parent = render 'projects/blob/header_content', blob: blob - .file-actions + .file-actions< = render 'projects/blob/viewer_switcher', blob: blob unless blame - - .btn-group{ role: "group" }< - = edit_blob_button - = ide_edit_button - .btn-group{ role: "group" }< + = edit_blob_button + = ide_edit_button + .btn-group.ml-2{ role: "group" }> = render_if_exists 'projects/blob/header_file_locks_link' - if current_user = replace_blob_link = delete_blob_link - .btn-group{ role: "group" }< + .btn-group.ml-2{ role: "group" } = copy_blob_source_button(blob) unless blame = open_raw_blob_button(blob) = download_blob_button(blob) diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml index 6a521069418..5e0d70b2ca9 100644 --- a/app/views/projects/blob/_viewer_switcher.html.haml +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -2,7 +2,7 @@ - simple_viewer = blob.simple_viewer - rich_viewer = blob.rich_viewer - .btn-group.js-blob-viewer-switcher{ role: "group" } + .btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }> - simple_label = "Display #{simple_viewer.switcher_title}" %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> = icon(simple_viewer.switcher_icon) diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index ed22573b23e..b12be8a91d6 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -1,11 +1,12 @@ - project = project || @project +- dropdown_class = local_assigns.fetch(:dropdown_class, '') -.git-clone-holder.js-git-clone-holder.input-group - %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } +.git-clone-holder.js-git-clone-holder + %a#clone-dropdown.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } %span.append-right-4.js-clone-dropdown-label = _('Clone') - = sprite_icon("arrow-down", css_class: "icon") - %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options + = sprite_icon("chevron-down", css_class: "icon") + %ul.p-3.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class } - if ssh_enabled? %li %label.label-bold diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index e8aff58b505..cae8bbf8c01 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -6,7 +6,7 @@ %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } = sprite_icon('download') %span.sr-only= _('Select Archive Format') - = sprite_icon("arrow-down") + = sprite_icon("chevron-down") .dropdown-menu.dropdown-menu-right{ role: 'menu' } %section %h5.m-0.dropdown-bold-header= _('Download source code') diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index f1a7528065a..33465953086 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -1,5 +1,5 @@ - can_create_issue = show_new_issue_link?(@project) -- can_create_project_snippet = can?(current_user, :create_project_snippet, @project) +- can_create_project_snippet = can?(current_user, :create_snippet, @project) - can_push_code = can?(current_user, :push_code, @project) - create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) - merge_project = merge_request_source_project_for_project(@project) diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index f4560404c03..18bdbd42d0d 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -46,7 +46,7 @@ - if job.try(:trigger_request) %span.badge.badge-info= _('triggered') - if job.try(:allow_failure) - %span.badge.badge-danger= _('allowed to fail') + %span.badge.badge-warning= _('allowed to fail') - if job.schedulable? %span.badge.badge-info= s_('DelayedJobs|delayed') - elsif job.action? diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 145bc629380..aa7c90bad66 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,2 +1,3 @@ - if signature - = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature } + - uri = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}" + = render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index cbd998c60ef..8ecaa1329fd 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,12 +17,18 @@ - content = capture do - if show_user .clearfix - = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature } + - uri_signature_badge_user = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}signature_badge_user" + = render partial: "#{uri_signature_badge_user}", locals: { signature: signature } - = _('GPG Key ID:') - %span.monospace= signature.gpg_key_primary_keyid + - if signature.instance_of?(X509CommitSignature) + = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature } - = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about x509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link') + - else + = _('GPG Key ID:') + %span.monospace= signature.gpg_key_primary_keyid -%button{ tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } + = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + +%a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/commit/x509/_certificate_details.html.haml b/app/views/projects/commit/x509/_certificate_details.html.haml new file mode 100644 index 00000000000..2357c6d803b --- /dev/null +++ b/app/views/projects/commit/x509/_certificate_details.html.haml @@ -0,0 +1,17 @@ +.gpg-popover-certificate-details + %strong= _('Certificate Subject') + %ul + - signature.x509_certificate.subject.split(",").each do |i| + - if i.start_with?("CN", "O") + %li= i + %li= _('Subject Key Identifier:') + %li.unstyled= signature.x509_certificate.subject_key_identifier.gsub(":", " ") + +.gpg-popover-certificate-details + %strong= _('Certificate Issuer') + %ul + - signature.x509_certificate.x509_issuer.subject.split(",").each do |i| + - if i.start_with?("CN", "OU", "O") + %li= i + %li= _('Subject Key Identifier:') + %li.unstyled= signature.x509_certificate.x509_issuer.subject_key_identifier.gsub(":", " ") diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml new file mode 100644 index 00000000000..b64ccba2a18 --- /dev/null +++ b/app/views/projects/commit/x509/_signature_badge_user.html.haml @@ -0,0 +1,19 @@ +- user = signature.commit.committer +- user_email = signature.x509_certificate.email + +- if user + = link_to user_path(user), class: 'gpg-popover-user-link' do + %div + = user_avatar_without_link(user: user, size: 32) + + %div + %strong= user.name + %div= user.to_reference + +- else + = mail_to user_email do + %div + = user_avatar_without_link(user_email: user_email, size: 32) + + %div + %strong= user_email diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml new file mode 100644 index 00000000000..680cc32c7e6 --- /dev/null +++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe + +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/x509/_verified_signature_badge.html.haml b/app/views/projects/commit/x509/_verified_signature_badge.html.haml new file mode 100644 index 00000000000..4964b1b8ee7 --- /dev/null +++ b/app/views/projects/commit/x509/_verified_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe + +- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index c8c96297672..f5a4889b4bb 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -10,7 +10,7 @@ = hidden_field_tag :to, params[:to] = button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag") - = sprite_icon('arrow-down', size: 16, css_class: 'float-right') + = sprite_icon('chevron-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown @@ -21,7 +21,7 @@ = hidden_field_tag :from, params[:from] = button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag") - = sprite_icon('arrow-down', size: 16, css_class: 'float-right') + = sprite_icon('chevron-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn" diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml index ea94b637f89..2ca72b141be 100644 --- a/app/views/projects/cycle_analytics/_overview.html.haml +++ b/app/views/projects/cycle_analytics/_overview.html.haml @@ -4,12 +4,12 @@ .col-md-10.offset-md-1 .row.overview-details .col-md-6.overview-text - %h4 Introducing Cycle Analytics + %h4 Introducing Value Stream Analytics %p - Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. - To set up CA, you must first define a production environment by setting up your CI and then deploy to production. + Value Stream Analytics (VSA) gives an overview of how much time it takes to go from idea to production in your project. + To set up VSA, you must first define a production environment by setting up your CI and then deploy to production. %p - %a.btn{ href: help_page_path('user/analytics/cycle_analytics.md'), target: '_blank' } Read more + %a.btn{ href: help_page_path('user/analytics/value_stream_analytics.md'), target: '_blank' } Read more .col-md-6.overview-image %span.overview-icon = custom_icon ('icon_cycle_analytics_overview') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 8bbe4e66c50..b0d9dfb0d37 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,11 +1,11 @@ -- page_title "Cycle Analytics" +- page_title "Value Stream Analytics" #cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data %banner{ "v-if" => "!isOverviewDialogDismissed", - "documentation-link": help_page_path('user/analytics/cycle_analytics.md'), + "documentation-link": help_page_path('user/analytics/value_stream_analytics.md'), "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" } - = icon("spinner spin", "v-show" => "isLoading") + %gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" } .wrapper{ "v-show" => "!isLoading && !hasError" } .card .card-header @@ -57,8 +57,7 @@ %ul %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" } .section.stage-events - %template{ "v-if" => "isLoadingStage" } - = icon("spinner spin") + %gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" } %template{ "v-if" => "currentStage && !currentStage.isUserAllowed" } = render partial: "no_access" %template{ "v-else" => true } diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index a9b6b397968..9e06358beba 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -11,9 +11,14 @@ - if @project.can_current_user_push_code? %p.append-bottom-0 - = _('You can create files directly in GitLab using one of the following options.') + = _('You can get started by cloning the repository or start adding files to it with one of the following options.') .project-buttons.qa-quick-actions + .project-clone-holder.d-block.d-md-none.mt-2.mr-2 + = render "shared/mobile_clone_panel" + + .project-clone-holder.d-none.d-md-inline-block.mt-2.mr-2.float-left + = render "projects/buttons/clone" = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons - if can?(current_user, :push_code, @project) diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 93a43b5d1ea..b38449b3ab9 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -7,20 +7,7 @@ %p = _("Measured in bytes of code. Excludes generated and vendored code.") - .row - .col-md-4 - %ul.bordered-list - - @languages.each do |language| - %li - %span{ style: "color: #{language[:color]}" } - = icon('circle') - - = language[:label] - .float-right - = language[:value] - \% - .col-md-8 - %canvas#languages-chart{ height: 400 } + #js-languages-chart{ data: { chart_data: @languages.to_json.html_safe } } .repo-charts .sub-header-block.border-top @@ -60,27 +47,18 @@ %p.slead = _("Commits per day of month") %div - %canvas#month-chart + #js-month-chart{ data: { chart_data: @commits_per_month.to_json.html_safe } } .row .col-md-6 .col-md-6 %p.slead = _("Commits per weekday") %div - %canvas#weekday-chart + #js-weekday-chart{ data: { chart_data: @commits_per_week_days.to_json.html_safe } } .row .col-md-6 .col-md-6 %p.slead = _("Commits per day hour (UTC)") %div - %canvas#hour-chart - --# haml-lint:disable InlineJavaScript -%script#projectChartData{ type: "application/json" } - - projectChartData = {}; - - projectChartData['hour'] = @commits_per_time - - projectChartData['weekDays'] = @commits_per_week_days - - projectChartData['month'] = @commits_per_month - - projectChartData['languages'] = @languages - = projectChartData.to_json.html_safe + #js-hour-chart{ data: { chart_data: @commits_per_time.to_json.html_safe } } diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index ada986dd969..f3cea6bea68 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -1,4 +1,4 @@ -.row.prepend-top-default.append-bottom-default +.row.prepend-top-32.append-bottom-default .col-lg-3 %h4.prepend-top-0 Recent Deliveries diff --git a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml index aff3fb82fa6..3ca82adccf1 100644 --- a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml +++ b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml @@ -8,6 +8,6 @@ %button.btn.btn-sm{ "@click" => "cancelDiscardConfirmation(file)" } Cancel .editor-wrap{ ":class" => "classObject" } .loading - %i.fa.fa-spinner.fa-spin + .spinner.spinner-md .editor %pre{ "style" => "height: 350px" } diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index f48390aa046..d933675eac5 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -11,7 +11,7 @@ #conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json), resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } } .loading{ "v-if" => "isLoading" } - %i.fa.fa-spinner.fa-spin + .spinner.spinner-md .nothing-here-block{ "v-if" => "hasError" } {{conflictsData.errorMessage}} diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index c6615b26bc0..99537ba8152 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -30,7 +30,8 @@ = dropdown_content = dropdown_loading .card-footer - .text-center= icon('spinner spin', class: 'js-source-loading') + .text-center + .js-source-loading.mt-1.spinner.spinner-sm %ul.list-unstyled.mr_source_commit .col-lg-6 @@ -58,7 +59,8 @@ = dropdown_content = dropdown_loading .card-footer - .text-center= icon('spinner spin', class: "js-target-loading") + .text-center + .js-target-loading.mt-1.spinner.spinner-sm %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 15c83f92474..0fb4d9ae70f 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -47,4 +47,5 @@ = render 'projects/merge_requests/pipelines', endpoint: url_for(safe_params.merge(action: 'pipelines', format: :json)), disable_initialization: true .mr-loading-status - = spinner + .loading.hide + .spinner.spinner-md diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 310cd355d22..d65c874f245 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -88,7 +88,8 @@ show_whitespace_default: @show_whitespace_default.to_s } .mr-loading-status - = spinner + .loading.hide + .spinner.spinner-md = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb index a0e82e891ff..a146d137c55 100644 --- a/app/views/projects/network/show.json.erb +++ b/app/views/projects/network/show.json.erb @@ -1,4 +1,4 @@ -<% self.formats = ["html"] %> +<% self.formats = [:html] %> <%= raw( { diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index ce6ae765de9..85902d51ab0 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -69,4 +69,11 @@ .icon-container = sprite_icon("git-merge") %span.related-merge-requests - = @pipeline.all_related_merge_request_text + %span.js-truncated-mr-list + = @pipeline.all_related_merge_request_text(limit: 1) + - if @pipeline.has_many_merge_requests? + = link_to("#", class: "js-toggle-mr-list") do + %span.text-expander + = sprite_icon('ellipsis_h', size: 12) + %span.js-full-mr-list.hide + = @pipeline.all_related_merge_request_text diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 4d8cba5168d..cdd75d43a78 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -18,7 +18,7 @@ %li.js-tests-tab-link = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do = s_('TestReports|Tests') - %span.badge.badge-pill= pipeline.test_reports.total_count + %span.badge.badge-pill.js-test-report-badge-counter = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index c9a50b97fea..7496ca97d56 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,6 +1,7 @@ - page_title _('CI / CD Charts') -#charts.ci-charts - = render 'projects/pipelines/charts/overall' - %hr - = render 'projects/pipelines/charts/pipelines' +#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts), + times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times }, + last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success }, + last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success }, + last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } } diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml deleted file mode 100644 index 651f9217455..00000000000 --- a/app/views/projects/pipelines/charts/_overall.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4.mt-4.mb-4= s_("PipelineCharts|Overall statistics") -.row - .col-md-6 - = render 'projects/pipelines/charts/pipeline_statistics' - .col-md-6 - = render 'projects/pipelines/charts/pipeline_times' diff --git a/app/views/projects/pipelines/charts/_pipeline_statistics.haml b/app/views/projects/pipelines/charts/_pipeline_statistics.haml deleted file mode 100644 index b323e290ed4..00000000000 --- a/app/views/projects/pipelines/charts/_pipeline_statistics.haml +++ /dev/null @@ -1,14 +0,0 @@ -%ul - %li - = s_("PipelineCharts|Total:") - %strong= n_("1 pipeline", "%d pipelines", @counts[:total]) % @counts[:total] - %li - = s_("PipelineCharts|Successful:") - %strong= n_("1 pipeline", "%d pipelines", @counts[:success]) % @counts[:success] - %li - = s_("PipelineCharts|Failed:") - %strong= n_("1 pipeline", "%d pipelines", @counts[:failed]) % @counts[:failed] - %li - = s_("PipelineCharts|Success ratio:") - %strong - #{success_ratio(@counts)}% diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml deleted file mode 100644 index c0ac79ed5f8..00000000000 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ /dev/null @@ -1,8 +0,0 @@ -%p.light - = _("Commit duration in minutes for last 30 commits") - -%div - %canvas#build_timesChart{ height: 200 } - --# haml-lint:disable InlineJavaScript -%script#pipelinesTimesChartsData{ type: "application/json" }= { :labels => @charts[:pipeline_times].labels, :values => @charts[:pipeline_times].pipeline_times }.to_json.html_safe diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml deleted file mode 100644 index afff9e82e45..00000000000 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ /dev/null @@ -1,37 +0,0 @@ -%h4.mt-4.mb-4= _("Pipelines charts") -%p - - %span.legend-success - = icon("circle") - = s_("Pipeline|success") - - %span.legend-all - = icon("circle") - = s_("Pipeline|all") - -.prepend-top-default - %p.light - = _("Pipelines for last week") - (#{date_from_to(Date.today - 7.days, Date.today)}) - %div - %canvas#weekChart{ height: 200 } - -.prepend-top-default - %p.light - = _("Pipelines for last month") - (#{date_from_to(Date.today - 30.days, Date.today)}) - %div - %canvas#monthChart{ height: 200 } - -.prepend-top-default - %p.light - = _("Pipelines for last year") - %div - %canvas#yearChart.padded{ height: 250 } - --# haml-lint:disable InlineJavaScript -%script#pipelinesChartsData{ type: "application/json" } - - chartData = [] - - [:week, :month, :year].each do |scope| - - chartData.push({ 'scope' => scope, 'labels' => @charts[scope].labels, 'totalValues' => @charts[scope].total, 'successValues' => @charts[scope].success }) - = chartData.to_json.html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index f0b3ab24ea0..f39968eecef 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -21,4 +21,5 @@ = render "projects/pipelines/with_tabs", pipeline: @pipeline .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), - test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json) } } + test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json), + test_reports_count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index b2e160e37bc..6ff7c27b1bc 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -3,12 +3,24 @@ %section .row.registry-placeholder.prepend-bottom-10 .col-12 - #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json), - "help_page_path" => help_page_path('user/packages/container_registry/index'), - "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), - "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), - "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), - "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => escape_once(@project.container_registry_url), - "registry_host_url_with_port" => escape_once(registry_config.host_port), - character_error: @character_error.to_s } } + - if Feature.enabled?(:vue_container_registry_explorer) + #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), + project_path: @project.full_path, + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => escape_once(@project.container_registry_url), + "registry_host_url_with_port" => escape_once(registry_config.host_port), + character_error: @character_error.to_s } } + - else + #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => escape_once(@project.container_registry_url), + "registry_host_url_with_port" => escape_once(registry_config.host_port), + character_error: @character_error.to_s } } diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml new file mode 100644 index 00000000000..188262fb34c --- /dev/null +++ b/app/views/projects/releases/show.html.haml @@ -0,0 +1,4 @@ +- add_to_breadcrumbs _("Releases"), project_releases_path(@project) +- page_title @release.name + +#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } } diff --git a/app/views/projects/services/alerts/_help.html.haml b/app/views/projects/services/alerts/_help.html.haml new file mode 100644 index 00000000000..be910203125 --- /dev/null +++ b/app/views/projects/services/alerts/_help.html.haml @@ -0,0 +1,3 @@ +.js-alerts-service-settings{ data: { activated: @service.activated?.to_s, + form_path: project_service_path(@project, @service.to_param), + authorization_key: @service.token, url: @service.url, learn_more_url: 'https://docs.gitlab.com/ee/user/project/integrations/generic_alerts.html' } } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index a65afeecc17..1358077f2b2 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -62,12 +62,12 @@ .settings-content = render 'projects/triggers/index' -- if Feature.enabled?(:registry_retention_policies_settings, @project) +- if settings_container_registry_expiration_policy_available?(@project) %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } .settings-header %h4 = _("Container Registry tag expiration policy") - = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml new file mode 100644 index 00000000000..756d4042613 --- /dev/null +++ b/app/views/projects/settings/operations/_incidents.html.haml @@ -0,0 +1,32 @@ +- templates = [] +- setting = project_incident_management_setting +- templates = setting.available_issue_templates.map { |t| [t.name, t.key] } + +%section.settings.no-animate.qa-incident-management-settings + .settings-header + %h4= _('Incidents') + %button.btn.js-settings-toggle{ type: 'button' } + = _('Expand') + %p + = _('Action to take when receiving an alert.') + = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-an-alert-ultimate') do + = _('More information') + .settings-content + = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f| + = form_errors(@project.incident_management_setting) + .form-group + = f.fields_for :incident_management_setting_attributes, setting do |form| + .form-group + = form.check_box :create_issue + = form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label' + .form-group.col-sm-8 + = form.label :issue_template_key, class: 'label-bold' do + = _('Issue template (optional)') + = link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer' + .select-wrapper + = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control" + = icon('chevron-down') + .form-group + = form.check_box :send_email + = form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label' + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 3c955e5f558..30b914b5199 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,7 +2,7 @@ - page_title _('Operations Settings') - breadcrumb_title _('Operations Settings') -= render_if_exists 'projects/settings/operations/incidents' += render 'projects/settings/operations/incidents' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/external_dashboard' = render 'projects/settings/operations/grafana_integration' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 8f13806e8cd..17bc10af58a 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -20,6 +20,7 @@ = render "archived_notice", project: @project = render_if_exists "projects/marked_for_deletion_notice", project: @project + = render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project - view_path = @project.default_view diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index 29bad50579c..41c9bac0102 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -1,33 +1,33 @@ - return unless current_user .d-none.d-sm-block - - if can?(current_user, :update_project_snippet, @snippet) + - if can?(current_user, :update_snippet, @snippet) = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do = _('Edit') - - if can?(current_user, :admin_project_snippet, @snippet) + - if can?(current_user, :admin_snippet, @snippet) = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _('Delete') - - if can?(current_user, :create_project_snippet, @project) + - if can?(current_user, :create_snippet, @project) = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-success', title: _("New snippet") do = _('New snippet') - if @snippet.submittable_as_spam_by?(current_user) = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') -- if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet) +- if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet) .d-block.d-sm-none.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } = _('Options') = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul - - if can?(current_user, :create_project_snippet, @project) + - if can?(current_user, :create_snippet, @project) %li = link_to new_project_snippet_path(@project), title: _("New snippet") do = _('New snippet') - - if can?(current_user, :admin_project_snippet, @snippet) + - if can?(current_user, :admin_snippet, @snippet) %li = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do = _('Delete') - - if can?(current_user, :update_project_snippet, @snippet) + - if can?(current_user, :update_snippet, @snippet) %li = link_to edit_project_snippet_path(@project, @snippet) do = _('Edit') diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml index 6dbd67df886..9f5af1cfe1e 100644 --- a/app/views/projects/snippets/edit.html.haml +++ b/app/views/projects/snippets/edit.html.haml @@ -1,6 +1,7 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title @snippet.to_reference - page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") +- @content_class = "limit-container-width" unless fluid_layout %h3.page-title = _("Edit Snippet") diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index 0ce18d83d57..a505b34f46c 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,15 +1,16 @@ - page_title _("Snippets") +- new_project_snippet_link = new_project_snippet_path(@project) if can?(current_user, :create_snippet, @project) - if @snippets.exists? - if current_user .top-area - include_private = @project.team.member?(current_user) || current_user.admin? - = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } + = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private, counts: @snippet_counts } - - if can?(current_user, :create_project_snippet, @project) + - if new_project_snippet_link.present? .nav-controls - = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") + = link_to _("New snippet"), new_project_snippet_link, class: "btn btn-success", title: _("New snippet") = render 'shared/snippets/list' - else - = render 'shared/empty_states/snippets', button_path: new_namespace_project_snippet_path(@project.namespace, @project) + = render 'shared/empty_states/snippets', button_path: new_project_snippet_link diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml index d64e3a49a81..d55a1160d48 100644 --- a/app/views/projects/snippets/new.html.haml +++ b/app/views/projects/snippets/new.html.haml @@ -1,6 +1,7 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title _("New") - page_title _("New Snippet") +- @content_class = "limit-container-width" unless fluid_layout %h3.page-title = _("New Snippet") diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 768e4422206..422a467574b 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -12,7 +12,7 @@ %article.file-holder.snippet-file-content = render 'shared/snippets/blob' - .row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true +.row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true +#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index cb459b031fc..c65420d537b 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,6 +1,6 @@ .tree-content-holder.js-tree-content{ data: tree_content_data(@logs_path, @project, @path) } .table-holder.bordered-box - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table qa-file-tree" } + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } %thead %tr %th= s_('ProjectFileTree|Name') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 2d987744dfd..4d3c24aee6b 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -25,7 +25,7 @@ %li.breadcrumb-item %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' } = sprite_icon('plus', size: 16, css_class: 'float-left') - = sprite_icon('arrow-down', size: 16, css_class: 'float-left') + = sprite_icon('chevron-down', size: 16, css_class: 'float-left') - if on_top_of_branch? .add-to-tree-dropdown %ul.dropdown-menu @@ -75,7 +75,7 @@ = link_to new_project_tag_path(@project) do #{ _('New tag') } -.tree-controls< +.tree-controls{ class: ("gl-font-size-0" if vue_file_list_enabled?) }< = render_if_exists 'projects/tree/lock_link' - if vue_file_list_enabled? #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } @@ -84,20 +84,25 @@ = render 'projects/find_file_link' - - if can_create_mr_from_fork - - if can_collaborate || current_user&.already_forked?(@project) - - if vue_file_list_enabled? - #js-tree-web-ide-link.d-inline-block - - else - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + - if can_collaborate || current_user&.already_forked?(@project) + - if vue_file_list_enabled? + #js-tree-web-ide-link.d-inline-block - else - = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do + = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do = _('Web IDE') - = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path) + - elsif can_create_mr_from_fork + = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do + = _('Web IDE') + = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path) - if show_xcode_link?(@project) .project-action-button.project-xcode.inline< = render "projects/buttons/xcode_link" = render 'projects/buttons/download', project: @project, ref: @ref + + .project-clone-holder.d-block.d-md-none.mt-sm-2.mt-md-0> + = render "shared/mobile_clone_panel" + + .project-clone-holder.d-none.d-md-inline-block> + = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index a153f527ee0..438d390389c 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -17,13 +17,19 @@ = icon('lightbulb-o') - if @page.persisted? = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") - = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank' + = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), + target: '_blank', rel: 'noopener noreferrer' - else = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.") + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/project/wiki/index', anchor: 'creating-a-new-wiki-page'), + target: '_blank', rel: 'noopener noreferrer' .form-group.row .col-sm-12= f.label :format, class: 'control-label-full-width' .col-sm-12 - = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control' + .select-wrapper + = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control' + = icon('chevron-down') .form-group.row .col-sm-12= f.label :content, class: 'control-label-full-width' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 629a5a045b1..8ada8c875f7 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -32,8 +32,7 @@ .term = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - else - - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope) - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals + = render partial: "search/results/#{@scope.singularize}", collection: @search_objects - if @scope != 'projects' = paginate_collection(@search_objects) diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index 4fb72b26955..6e17a25c713 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,7 +1,5 @@ -- project = find_project_for_result_blob(projects, blob) +- project = blob.project - return unless project - -- blob = parse_search_result(blob) - blob_link = project_blob_path(project, tree_join(blob.ref, blob.path)) = render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link } diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index 0b114bf67ee..5126351b0bb 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -3,17 +3,22 @@ - snippet_chunks = snippet_blob[:snippet_chunks] - snippet_path = gitlab_snippet_path(snippet) -.search-result-row - %span - = snippet.title +.search-result-row.snippet-row + = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' + .title + = link_to gitlab_snippet_path(snippet) do + = snippet.title + .snippet-info + = snippet.to_reference + · + authored + = time_ago_with_tooltip(snippet.created_at) by = link_to user_snippets_path(snippet.author) do - = image_tag avatar_icon_for_user(snippet.author), class: "avatar avatar-inline s16", alt: '' = snippet.author_name - %span.light= time_ago_with_tooltip(snippet.created_at) - %h4.snippet-title - .file-holder - .js-file-title.file-title + + .file-holder.my-2 + .js-file-title.file-title-flex-parent = link_to snippet_path do %i.fa.fa-file %strong= snippet.file_name diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 9afed2bbecc..3040917dd6e 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,4 @@ -- project = find_project_for_result_blob(projects, wiki_blob) -- wiki_blob = parse_search_result(wiki_blob) +- project = wiki_blob.project - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) = render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, path: wiki_blob.path, blob_link: wiki_blob_link } diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 3670e19c240..d378e6cb22c 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -10,4 +10,4 @@ - unless Gitlab.config.registry.enabled %div = icon('exclamation-triangle') - = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for AutoDevOps to work.') + = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml new file mode 100644 index 00000000000..c058b210688 --- /dev/null +++ b/app/views/shared/_broadcast_message.html.haml @@ -0,0 +1,8 @@ +%div{ class: "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} d-flex", + style: broadcast_message_style(message), dir: 'auto' } + %div + = sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top') + = render_broadcast_message(message) + - if message.notification? && opts[:preview].blank? + %button.js-dismiss-current-broadcast-notification.btn.btn-link.text-dark.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id } } + %i.fa.fa-times diff --git a/app/views/shared/_check_recovery_settings.html.haml b/app/views/shared/_check_recovery_settings.html.haml new file mode 100644 index 00000000000..e3de34a5ab9 --- /dev/null +++ b/app/views/shared/_check_recovery_settings.html.haml @@ -0,0 +1,6 @@ +.gl-alert.gl-alert-warning.js-recovery-settings-callout{ role: 'alert', data: { feature_id: "account_recovery_regular_check", dismiss_endpoint: user_callouts_path, defer_links: "true" } } + %button.js-close.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .gl-alert-body + - account_link_start = '<a class="deferred-link" href="%{url}">'.html_safe % { url: profile_account_path } + = _("Please ensure your account's %{account_link_start}recovery settings%{account_link_end} are up to date.").html_safe % { account_link_start: account_link_start, account_link_end: '</a>'.html_safe } diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 2887acf7cd7..2854b115506 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -4,8 +4,8 @@ .btn-group.mobile-git-clone.js-mobile-git-clone.btn-block = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") - %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center{ type: "button", data: { toggle: "dropdown" } } - = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") + %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } } + = sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } - if ssh_enabled? %li diff --git a/app/views/shared/_ping_consent.html.haml b/app/views/shared/_ping_consent.html.haml index f8eb2b2833b..ded9b55056a 100644 --- a/app/views/shared/_ping_consent.html.haml +++ b/app/views/shared/_ping_consent.html.haml @@ -1,6 +1,6 @@ - if session[:ask_for_usage_stats_consent] .ping-consent-message.alert.alert-warning.flex-alert - - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: admin_application_settings_path(anchor: 'js-usage-settings') } + - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings') } - info_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: help_page_path('user/admin_area/settings/usage_statistics.md') } .alert-message = s_('To help improve GitLab, we would like to periodically collect usage information. This can be changed at any time in %{settings_link_start}Settings%{link_end}. %{info_link_start}More Information%{link_end}').html_safe % { settings_link_start: settings_link_start, info_link_start: info_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 1bf52feab11..4415c654ab9 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -12,7 +12,7 @@ .form-group.row = form.label :active, "Active", class: "col-form-label col-sm-2" .col-sm-10 - = form.check_box :active, disabled: disable_fields_service?(@service), data: { qa_selector: 'active_checkbox' } + = form.check_box :active, checked: @service.active || @service.new_record?, disabled: disable_fields_service?(@service) - if @service.configurable_events.present? .form-group.row diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index a62c385d711..3db96db73ce 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -22,14 +22,14 @@ %span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"", ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" }, - ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type) }" } + ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type), 'd-block': list.type === 'milestone' }" } {{ list.title }} %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", ":title" => '(list.assignee && list.assignee.username || "")' } @{{ list.assignee.username }} - %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"", + %span.has-tooltip.badge.color-label.title.d-inline-block.mw-100.text-truncate.align-middle{ "v-if": "list.type === \"label\"", ":title" => '(list.label ? list.label.description : "")', data: { container: "body", placement: "bottom" }, ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml index 98a5a5953d0..38c9fe7179c 100644 --- a/app/views/shared/empty_states/_profile_tabs.html.haml +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -14,6 +14,7 @@ - if secondary_button_link.present? = link_to secondary_button_label, secondary_button_link, class: 'btn btn-success btn-inverted' - = link_to primary_button_label, primary_button_link, class: 'btn btn-success' + - if primary_button_link.present? + = link_to primary_button_label, primary_button_link, class: 'btn btn-success' - else %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index 889a470d6ec..efd9bceedc5 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -1,20 +1,19 @@ - button_path = local_assigns.fetch(:button_path, false) -.row.empty-state +.row.empty-state.mt-0 .col-12 .svg-content = image_tag 'illustrations/snippets_empty.svg' - .text-content + .text-content.text-center.pt-0 - if current_user %h4 - = s_('SnippetsEmptyState|Snippets are small pieces of code or notes that you want to keep.') - %p - = s_('SnippetsEmptyState|They can be either public or private.') - .text-center + = s_('SnippetsEmptyState|Code snippets') + %p.mb-0 + = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') + .mt-2< - if button_path = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link' - - unless current_page?(dashboard_snippets_path) - = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets') + = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn btn-default', title: s_('SnippetsEmptyState|Documentation') - else %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml index 993880b7d6e..dfa5ecee448 100644 --- a/app/views/shared/hook_logs/_status_label.html.haml +++ b/app/views/shared/hook_logs/_status_label.html.haml @@ -1,3 +1,3 @@ - label_status = hook_log.success? ? 'badge-success' : 'badge-danger' -%span{ class: "label #{label_status}" } - = hook_log.response_status +%span{ class: "badge #{label_status}" } + = hook_log.internal_error? ? _('Error') : hook_log.response_status diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index c3960ec5026..a27ceaff782 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -21,7 +21,7 @@ - if type != :boards_modal && type != :boards = dropdown_tag(_('Recent searches'), options: { wrapper_class: "filtered-search-history-dropdown-wrapper", - toggle_class: "filtered-search-history-dropdown-toggle-button", + toggle_class: "btn filtered-search-history-dropdown-toggle-button", dropdown_class: "filtered-search-history-dropdown", content_class: "filtered-search-history-dropdown-content" }) do .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 4aeeac87f3c..1d7d18d2ab6 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -31,7 +31,7 @@ = dropdown_title(_("Change permissions")) .dropdown-content %ul - - Gitlab::Access.options.each do |role, role_id| + - Gitlab::Access.options_with_owner.each do |role, role_id| %li = link_to role, '#', class: ("is-active" if group_link.group_access == role_id), diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index d5c1a1bee6d..d74030c566f 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -22,6 +22,8 @@ - if user == current_user %span.badge.badge-success.prepend-left-5= _("It's you") + = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group + - if user.blocked? %label.badge.badge-danger %strong= _("Blocked") diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml index e236c24b088..e00a10398d3 100644 --- a/app/views/shared/milestones/_delete_button.html.haml +++ b/app/views/shared/milestones/_delete_button.html.haml @@ -9,6 +9,6 @@ milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true } = _('Delete') - = icon('spin spinner', class: 'js-loading-icon hidden' ) + .spinner.js-loading-icon.hidden #delete-milestone-modal diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index fbbcc4f3e68..a6fb8e6d4fc 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -98,10 +98,6 @@ human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent, limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } } - // Fallback while content is loading - .title.hide-collapsed - = _('Time tracking') - = icon('spinner spin') = render_if_exists 'shared/milestones/weight', milestone: milestone diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml index 68458c2d0aa..dfca6a184be 100644 --- a/app/views/shared/milestones/_tab_loading.html.haml +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -1,2 +1,2 @@ .text-center.prepend-top-default - = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content') + .spinner.spinner-md diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index be574155436..0e1e3beeb1c 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -30,4 +30,4 @@ %label.form-check-label{ for: field_id } %strong = notification_event_name(event) - = icon("spinner spin", class: "custom-notification-event-loading") + .fa.custom-notification-event-loading.spinner diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index 363053b5e35..566f08b94ce 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -20,13 +20,13 @@ = notification_setting_icon(notification_setting) %span.js-notification-loading.fa.hidden %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" } - = sprite_icon("arrow-down", css_class: "icon mr-0") + = sprite_icon("chevron-down", css_class: "icon mr-0") .sr-only Toggle dropdown - else %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => button_title, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = notification_setting_icon(notification_setting) %span.js-notification-loading.fa.hidden - = sprite_icon("arrow-down", css_class: "icon") + = sprite_icon("chevron-down", css_class: "icon") = render "shared/notifications/notification_dropdown", notification_setting: notification_setting diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 45e95685677..07a61b71b8e 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,9 +12,7 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project, pipeline_status: pipeline_status) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) - css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"] -- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon - avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' %li.project-row.d-flex{ class: css_class } @@ -62,11 +60,6 @@ .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") } .icon-container.d-flex.align-items-center - - if show_pipeline_status_icon - - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) - %span.icon-wrapper.pipeline-status - = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path - = render_if_exists 'shared/projects/archived', project: project - if stars = link_to project_starrers_path(project), diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 73401029da4..3c2c751c579 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -6,27 +6,37 @@ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| = form_errors(@snippet) - .form-group.row - .col-sm-2.col-form-label - = f.label :title - .col-sm-10 - = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true - - = render 'shared/form_elements/description', model: @snippet, project: @project, form: f - - = render 'shared/old_visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false - - .file-editor - .form-group.row - .col-sm-2.col-form-label - = f.label :file_name, "File" - .col-sm-10 - .file-holder.snippet - .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name qa-snippet-file-name' - .file-content.code - %pre#editor= @snippet.content - = f.hidden_field :content, class: 'snippet-file-content' + .form-group + = f.label :title, class: 'label-bold' + = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true + + .form-group.js-description-input + - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') + - is_expanded = @snippet.description && !@snippet.description.empty? + = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' + .js-collapsible-input + .js-collapsed{ class: ('d-none' if is_expanded) } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + .js-expanded{ class: ('d-none' if !is_expanded) } + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' + = render 'shared/notes/hints' + + .form-group.file-editor + = f.label :file_name, s_('Snippets|File') + .file-holder.snippet + .js-file-title.file-title-flex-parent + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control snippet-file-name qa-snippet-file-name' + .file-content.code + %pre#editor= @snippet.content + = f.hidden_field :content, class: 'snippet-file-content' + + .form-group + .font-weight-bold + = _('Visibility level') + = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' + = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false + - if params[:files] - params[:files].each_with_index do |file, index| = hidden_field_tag "files[]", file, id: "files_#{index}" diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml index 52c7bc47ca7..1514ad55d71 100644 --- a/app/views/sherlock/queries/_general.html.haml +++ b/app/views/sherlock/queries/_general.html.haml @@ -27,7 +27,7 @@ .card-header .float-right %button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button } - = sprite_icon('duplicate') + = sprite_icon('copy-to-clipboard') %pre.hidden = @query.formatted_query %strong @@ -42,7 +42,7 @@ .card-header .float-right %button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button } - = sprite_icon('duplicate') + = sprite_icon('copy-to-clipboard') %pre.hidden = @query.explain %strong diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 5ee12a2f22a..979821a3846 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -1,13 +1,13 @@ - return unless current_user .d-none.d-sm-block - - if can?(current_user, :update_personal_snippet, @snippet) + - if can?(current_user, :update_snippet, @snippet) = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do = _("Edit") - - if can?(current_user, :admin_personal_snippet, @snippet) + - if can?(current_user, :admin_snippet, @snippet) = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _("Delete") - - if can?(current_user, :create_personal_snippet) + - if can?(current_user, :create_snippet) = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) @@ -18,15 +18,15 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul - - if can?(current_user, :create_personal_snippet) + - if can?(current_user, :create_snippet) %li = link_to new_snippet_path, title: _("New snippet") do = _("New snippet") - - if can?(current_user, :admin_personal_snippet, @snippet) + - if can?(current_user, :admin_snippet, @snippet) %li = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do = _("Delete") - - if can?(current_user, :update_personal_snippet, @snippet) + - if can?(current_user, :update_snippet, @snippet) %li = link_to edit_snippet_path(@snippet) do = _("Edit") diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 69b19c0def9..1d22575803b 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -3,7 +3,7 @@ - current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.') - current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.') - primary_button_label = _('New snippet') -- primary_button_link = new_snippet_path if can?(current_user, :create_personal_snippet) +- primary_button_link = new_snippet_path if can?(current_user, :create_snippet) - visitor_empty_message = s_('UserProfile|No snippets found.') .snippets-list-holder diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index cb59b11ca2b..e9c9ca6e856 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -7,25 +7,25 @@ = _("All") %span.badge.badge-pill - if include_private - = subject.snippets.count + = counts[:total] - else - = subject.snippets.public_and_internal_only.count + = counts[:are_public_or_internal] - if include_private %li{ class: active_when(params[:scope] == "are_private") } = link_to subject_snippets_path(subject, scope: 'are_private') do = _("Private") %span.badge.badge-pill - = subject.snippets.are_private.count + = counts[:are_private] %li{ class: active_when(params[:scope] == "are_internal") } = link_to subject_snippets_path(subject, scope: 'are_internal') do = _("Internal") %span.badge.badge-pill - = subject.snippets.are_internal.count + = counts[:are_internal] %li{ class: active_when(params[:scope] == "are_public") } = link_to subject_snippets_path(subject, scope: 'are_public') do = _("Public") %span.badge.badge-pill - = subject.snippets.are_public.count + = counts[:are_public] diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index f5ffb037152..66f5e8148e1 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -1,4 +1,5 @@ - page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") +- @content_class = "limit-container-width" unless fluid_layout %h3.page-title = _("Edit Snippet") diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml index 9d462865471..acc0ce0fff3 100644 --- a/app/views/snippets/new.html.haml +++ b/app/views/snippets/new.html.haml @@ -1,6 +1,7 @@ - @hide_top_links = true - @hide_breadcrumbs = true - page_title _("New Snippet") +- @content_class = "limit-container-width" unless fluid_layout .page-title-holder.d-flex.align-items-center %h1.page-title= _('New Snippet') diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 080c0ab6ece..30f760f2122 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -13,7 +13,7 @@ %article.file-holder.snippet-file-content = render 'shared/snippets/blob' - .row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true +.row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false +#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index b5bc1180290..7bd2d30a35c 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -3,7 +3,7 @@ .calendar-block.prepend-top-default.append-bottom-default .user-calendar.d-none.d-sm-block{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } } %h4.center.light - = spinner nil, true + .spinner.spinner-md .user-calendar-activities.d-none.d-sm-block .row .col-md-12.col-lg-6 @@ -16,7 +16,7 @@ = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" .overview-content-list{ data: { href: user_path } } .center.light.loading - = spinner nil, true + .spinner.spinner-md .col-md-12.col-lg-6 .projects-block @@ -27,4 +27,4 @@ = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" .overview-content-list{ data: { href: user_projects_path } } .center.light.loading - = spinner nil, true + .spinner.spinner-md diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index e10dad8aa8d..3c164588b13 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -130,7 +130,8 @@ %h4.prepend-top-20 = s_('UserProfile|Most Recent Activity') .content_list{ data: { href: user_path } } - = spinner + .loading + .spinner.spinner-md - if profile_tab?(:groups) #groups.tab-pane @@ -152,8 +153,8 @@ #snippets.tab-pane -# This tab is always loaded via AJAX - .loading-status - = spinner + .loading.hide + .spinner.spinner-md - if profile_tabs.empty? .row diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index be05d2a6752..a7cc4fb0d11 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -2,7 +2,10 @@ class AdminEmailWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category_not_owned! diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 62b37f52cce..f6daab73689 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1,192 +1,1085 @@ +# This file is generated automatically by +# bin/rake gitlab:sidekiq:all_queues_yml:generate +# +# Do not edit it manually! --- -- auto_devops:auto_devops_disable - -- auto_merge:auto_merge_process - -- chaos:chaos_cpu_spin -- chaos:chaos_db_spin -- chaos:chaos_kill -- chaos:chaos_leak_mem -- chaos:chaos_sleep - -- cronjob:admin_email -- cronjob:container_expiration_policy -- cronjob:expire_build_artifacts -- cronjob:gitlab_usage_ping -- cronjob:import_export_project_cleanup -- cronjob:pages_domain_verification_cron -- cronjob:pages_domain_removal_cron -- cronjob:pages_domain_ssl_renewal_cron -- cronjob:personal_access_tokens_expiring -- cronjob:pipeline_schedule -- cronjob:prune_old_events -- cronjob:remove_expired_group_links -- cronjob:remove_expired_members -- cronjob:remove_unreferenced_lfs_objects -- cronjob:repository_archive_cache -- cronjob:repository_check_dispatch -- cronjob:requests_profiles -- cronjob:stuck_ci_jobs -- cronjob:stuck_import_jobs -- cronjob:stuck_merge_jobs -- cronjob:ci_archive_traces_cron -- cronjob:trending_projects -- cronjob:issue_due_scheduler -- cronjob:prune_web_hook_logs -- cronjob:schedule_migrate_external_diffs -- cronjob:namespaces_prune_aggregation_schedules - -- gcp_cluster:cluster_install_app -- gcp_cluster:cluster_patch_app -- gcp_cluster:cluster_upgrade_app -- gcp_cluster:cluster_provision -- gcp_cluster:clusters_cleanup_app -- gcp_cluster:clusters_cleanup_project_namespace -- gcp_cluster:clusters_cleanup_service_account -- gcp_cluster:cluster_wait_for_app_installation -- gcp_cluster:wait_for_cluster_creation -- gcp_cluster:cluster_wait_for_ingress_ip_address -- gcp_cluster:cluster_configure -- gcp_cluster:cluster_project_configure -- gcp_cluster:clusters_applications_wait_for_uninstall_app -- gcp_cluster:clusters_applications_uninstall -- gcp_cluster:clusters_cleanup_app -- gcp_cluster:clusters_cleanup_project_namespace -- gcp_cluster:clusters_cleanup_service_account -- gcp_cluster:clusters_applications_activate_service -- gcp_cluster:clusters_applications_deactivate_service - -- github_import_advance_stage -- github_importer:github_import_import_diff_note -- github_importer:github_import_import_issue -- github_importer:github_import_import_note -- github_importer:github_import_import_lfs_object -- github_importer:github_import_import_pull_request -- github_importer:github_import_refresh_import_jid -- github_importer:github_import_stage_finish_import -- github_importer:github_import_stage_import_base_data -- github_importer:github_import_stage_import_issues_and_diff_notes -- github_importer:github_import_stage_import_notes -- github_importer:github_import_stage_import_lfs_objects -- github_importer:github_import_stage_import_pull_requests -- github_importer:github_import_stage_import_repository - -- hashed_storage:hashed_storage_migrator -- hashed_storage:hashed_storage_rollbacker -- hashed_storage:hashed_storage_project_migrate -- hashed_storage:hashed_storage_project_rollback - -- mail_scheduler:mail_scheduler_issue_due -- mail_scheduler:mail_scheduler_notification_service - -- object_storage:object_storage_background_move -- object_storage:object_storage_migrate_uploads - -- pipeline_cache:expire_job_cache -- pipeline_cache:expire_pipeline_cache -- pipeline_creation:create_pipeline -- pipeline_creation:run_pipeline_schedule -- pipeline_background:archive_trace -- pipeline_background:ci_build_trace_chunk_flush -- pipeline_default:build_coverage -- pipeline_default:build_trace_sections -- pipeline_default:pipeline_metrics -- pipeline_default:pipeline_notification -- pipeline_hooks:build_hooks -- pipeline_hooks:pipeline_hooks -- pipeline_processing:build_finished -- pipeline_processing:ci_build_prepare -- pipeline_processing:build_queue -- pipeline_processing:build_success -- pipeline_processing:pipeline_process -- pipeline_processing:pipeline_success -- pipeline_processing:pipeline_update -- pipeline_processing:stage_update -- pipeline_processing:update_head_pipeline_for_merge_request -- pipeline_processing:ci_build_schedule -- pipeline_processing:ci_resource_groups_assign_resource_from_resource_group - -- deployment:deployments_success -- deployment:deployments_finished - -- repository_check:repository_check_clear -- repository_check:repository_check_batch -- repository_check:repository_check_single_repository - -- todos_destroyer:todos_destroyer_confidential_issue -- todos_destroyer:todos_destroyer_entity_leave -- todos_destroyer:todos_destroyer_group_private -- todos_destroyer:todos_destroyer_project_private -- todos_destroyer:todos_destroyer_private_features - -- update_namespace_statistics:namespaces_schedule_aggregation -- update_namespace_statistics:namespaces_root_statistics - -- object_pool:object_pool_create -- object_pool:object_pool_schedule_join -- object_pool:object_pool_join -- object_pool:object_pool_destroy - -- container_repository:delete_container_repository -- container_repository:cleanup_container_repository - -- notifications:new_release - -- default -- mailers # ActionMailer::DeliveryJob.queue_name - -- authorized_projects -- background_migration -- chat_notification -- create_gpg_signature -- delete_merged_branches -- delete_user -- email_receiver -- emails_on_push -- expire_build_instance_artifacts -- git_garbage_collect -- gitlab_shell -- group_destroy -- invalid_gpg_signature_update -- irker -- merge -- migrate_external_diffs -- namespaceless_project_destroy -- new_issue -- new_merge_request -- new_note -- pages -- pages_domain_verification -- pages_domain_ssl_renewal -- file_hook -- post_receive -- process_commit -- project_cache -- project_destroy -- project_export -- project_service -- propagate_service_template -- reactive_caching -- rebase -- remote_mirror_notification -- repository_fork -- repository_import -- repository_remove_remote -- system_hook_push -- update_external_pull_requests -- update_merge_requests -- update_project_statistics -- upload_checksum -- web_hook -- repository_update_remote_mirror -- create_note_diff_file -- delete_diff_files -- detect_repository_languages -- repository_cleanup -- delete_stored_files -- import_issues_csv -- project_daily_statistics -- create_evidence -- group_export -- self_monitoring_project_create -- self_monitoring_project_delete +- :name: auto_devops:auto_devops_disable + :feature_category: :auto_devops + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: auto_merge:auto_merge_process + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 3 +- :name: chaos:chaos_cpu_spin + :feature_category: :chaos_engineering + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: chaos:chaos_db_spin + :feature_category: :chaos_engineering + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: chaos:chaos_kill + :feature_category: :chaos_engineering + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: chaos:chaos_leak_mem + :feature_category: :chaos_engineering + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: chaos:chaos_sleep + :feature_category: :chaos_engineering + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: container_repository:cleanup_container_repository + :feature_category: :container_registry + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: container_repository:delete_container_repository + :feature_category: :container_registry + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:admin_email + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:ci_archive_traces_cron + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:container_expiration_policy + :feature_category: :container_registry + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:environments_auto_stop_cron + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:expire_build_artifacts + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:gitlab_usage_ping + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:import_export_project_cleanup + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:issue_due_scheduler + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:namespaces_prune_aggregation_schedules + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:pages_domain_removal_cron + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:pages_domain_ssl_renewal_cron + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:pages_domain_verification_cron + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:personal_access_tokens_expiring + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:pipeline_schedule + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:prune_old_events + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:prune_web_hook_logs + :feature_category: :integrations + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:remove_expired_group_links + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:remove_expired_members + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:remove_unreferenced_lfs_objects + :feature_category: :git_lfs + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:repository_archive_cache + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:repository_check_dispatch + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:requests_profiles + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:schedule_migrate_external_diffs + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:stuck_ci_jobs + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:stuck_import_jobs + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: cronjob:stuck_merge_jobs + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: cronjob:trending_projects + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: deployment:deployments_finished + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 3 +- :name: deployment:deployments_forward_deployment + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 3 +- :name: deployment:deployments_success + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 3 +- :name: gcp_cluster:cluster_configure + :feature_category: :kubernetes_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_configure_istio + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_install_app + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_patch_app + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_project_configure + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_provision + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_upgrade_app + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:cluster_wait_for_app_installation + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: gcp_cluster:cluster_wait_for_ingress_ip_address + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_applications_activate_service + :feature_category: :kubernetes_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_applications_deactivate_service + :feature_category: :kubernetes_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_applications_uninstall + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_applications_wait_for_uninstall_app + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: gcp_cluster:clusters_cleanup_app + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_cleanup_project_namespace + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:clusters_cleanup_service_account + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gcp_cluster:wait_for_cluster_creation + :feature_category: :kubernetes_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_import_diff_note + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_import_issue + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_import_lfs_object + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_import_note + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_import_pull_request + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_refresh_import_jid + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_finish_import + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_base_data + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_issues_and_diff_notes + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_lfs_objects + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_notes + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_pull_requests + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_importer:github_import_stage_import_repository + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: hashed_storage:hashed_storage_migrator + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: hashed_storage:hashed_storage_project_migrate + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: hashed_storage:hashed_storage_project_rollback + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: hashed_storage:hashed_storage_rollbacker + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: incident_management:incident_management_process_alert + :feature_category: :incident_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: mail_scheduler:mail_scheduler_issue_due + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: mail_scheduler:mail_scheduler_notification_service + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 2 +- :name: notifications:new_release + :feature_category: :release_orchestration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: object_pool:object_pool_create + :feature_category: :gitaly + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: object_pool:object_pool_destroy + :feature_category: :gitaly + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: object_pool:object_pool_join + :feature_category: :gitaly + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 1 +- :name: object_pool:object_pool_schedule_join + :feature_category: :gitaly + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: object_storage:object_storage_background_move + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: object_storage:object_storage_migrate_uploads + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: pipeline_background:archive_trace + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: pipeline_background:ci_build_trace_chunk_flush + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: pipeline_cache:expire_job_cache + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 3 +- :name: pipeline_cache:expire_pipeline_cache + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 3 +- :name: pipeline_creation:create_pipeline + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 4 +- :name: pipeline_creation:run_pipeline_schedule + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 4 +- :name: pipeline_default:build_coverage + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 3 +- :name: pipeline_default:build_trace_sections + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 3 +- :name: pipeline_default:ci_create_cross_project_pipeline + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 3 +- :name: pipeline_default:ci_pipeline_bridge_status + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 3 +- :name: pipeline_default:pipeline_metrics + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 3 +- :name: pipeline_default:pipeline_notification + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 3 +- :name: pipeline_hooks:build_hooks + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 2 +- :name: pipeline_hooks:pipeline_hooks + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 2 +- :name: pipeline_processing:build_finished + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 5 +- :name: pipeline_processing:build_queue + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 5 +- :name: pipeline_processing:build_success + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:ci_build_prepare + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:ci_build_schedule + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 5 +- :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group + :feature_category: :continuous_delivery + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:pipeline_process + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:pipeline_success + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:pipeline_update + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:stage_update + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: pipeline_processing:update_head_pipeline_for_merge_request + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 5 +- :name: repository_check:repository_check_batch + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_check:repository_check_clear + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_check:repository_check_single_repository + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: todos_destroyer:todos_destroyer_confidential_issue + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: todos_destroyer:todos_destroyer_entity_leave + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: todos_destroyer:todos_destroyer_group_private + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: todos_destroyer:todos_destroyer_private_features + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: todos_destroyer:todos_destroyer_project_private + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: update_namespace_statistics:namespaces_root_statistics + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: update_namespace_statistics:namespaces_schedule_aggregation + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: authorized_projects + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 2 +- :name: background_migration + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: chat_notification + :feature_category: :chatops + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 2 +- :name: create_commit_signature + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: create_evidence + :feature_category: :release_governance + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: create_note_diff_file + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: default + :feature_category: + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: + :weight: 1 +- :name: delete_diff_files + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: delete_merged_branches + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: delete_stored_files + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: delete_user + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: detect_repository_languages + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: email_receiver + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 2 +- :name: emails_on_push + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 2 +- :name: error_tracking_issue_link + :feature_category: :error_tracking + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: expire_build_instance_artifacts + :feature_category: :continuous_integration + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: file_hook + :feature_category: :integrations + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: git_garbage_collect + :feature_category: :gitaly + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: github_import_advance_stage + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: gitlab_shell + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 2 +- :name: group_destroy + :feature_category: :subgroups + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: group_export + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: group_import + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: import_issues_csv + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :cpu + :weight: 2 +- :name: invalid_gpg_signature_update + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: irker + :feature_category: :integrations + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: mailers + :feature_category: + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: + :weight: 2 +- :name: merge + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 5 +- :name: merge_request_mergeability_check + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: migrate_external_diffs + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: namespaceless_project_destroy + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: new_issue + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 2 +- :name: new_merge_request + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 2 +- :name: new_note + :feature_category: :issue_tracking + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 2 +- :name: pages + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: pages_domain_ssl_renewal + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: pages_domain_verification + :feature_category: :pages + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: phabricator_import_import_tasks + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: post_receive + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 5 +- :name: process_commit + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 3 +- :name: project_cache + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :unknown + :weight: 1 +- :name: project_daily_statistics + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: project_destroy + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: project_export + :feature_category: :importers + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :memory + :weight: 1 +- :name: project_service + :feature_category: :integrations + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: propagate_service_template + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: reactive_caching + :feature_category: :not_owned + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 1 +- :name: rebase + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: remote_mirror_notification + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: repository_cleanup + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_fork + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_import + :feature_category: :importers + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_remove_remote + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: repository_update_remote_mirror + :feature_category: :source_code_management + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: self_monitoring_project_create + :feature_category: :metrics + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: self_monitoring_project_delete + :feature_category: :metrics + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 +- :name: system_hook_push + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: update_external_pull_requests + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 3 +- :name: update_merge_requests + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: true + :resource_boundary: :cpu + :weight: 3 +- :name: update_project_statistics + :feature_category: :source_code_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: upload_checksum + :feature_category: :geo_replication + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 +- :name: web_hook + :feature_category: :integrations + :has_external_dependencies: true + :latency_sensitive: + :resource_boundary: :unknown + :weight: 1 diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 9492cfe217c..1ab2fd6023f 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -6,6 +6,7 @@ class AuthorizedProjectsWorker feature_category :authentication_and_authorization latency_sensitive_worker! + weight 2 # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb index e4dccb891ce..1681fac3363 100644 --- a/app/workers/auto_merge_process_worker.rb +++ b/app/workers/auto_merge_process_worker.rb @@ -5,6 +5,7 @@ class AutoMergeProcessWorker queue_namespace :auto_merge feature_category :continuous_delivery + worker_resource_boundary :cpu def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index e61f37ddce1..77ce0923307 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -32,7 +32,7 @@ class BuildFinishedWorker # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ArchiveTraceWorker.perform_async(build.id) - ExpirePipelineCacheWorker.perform_async(build.pipeline_id) + ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable? ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? end end diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 6162dcf9d38..f23c787559f 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -8,6 +8,8 @@ class ChatNotificationWorker sidekiq_options retry: false feature_category :chatops latency_sensitive_worker! + weight 2 + # TODO: break this into multiple jobs # as the `responder` uses external dependencies # See https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 74f389175b9..c73c7ba2dd8 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -3,7 +3,7 @@ module Ci class ArchiveTracesCronWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :continuous_integration diff --git a/app/workers/ci/create_cross_project_pipeline_worker.rb b/app/workers/ci/create_cross_project_pipeline_worker.rb new file mode 100644 index 00000000000..91e9317713e --- /dev/null +++ b/app/workers/ci/create_cross_project_pipeline_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class CreateCrossProjectPipelineWorker + include ::ApplicationWorker + include ::PipelineQueue + + worker_resource_boundary :cpu + + def perform(bridge_id) + ::Ci::Bridge.find_by_id(bridge_id).try do |bridge| + ::Ci::CreateCrossProjectPipelineService + .new(bridge.project, bridge.user) + .execute(bridge) + end + end + end +end diff --git a/app/workers/ci/pipeline_bridge_status_worker.rb b/app/workers/ci/pipeline_bridge_status_worker.rb new file mode 100644 index 00000000000..f196573deaa --- /dev/null +++ b/app/workers/ci/pipeline_bridge_status_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class PipelineBridgeStatusWorker + include ::ApplicationWorker + include ::PipelineQueue + + latency_sensitive_worker! + worker_resource_boundary :cpu + + def perform(pipeline_id) + ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + ::Ci::PipelineBridgeStatusService + .new(pipeline.project, pipeline.user) + .execute(pipeline) + end + end + end +end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 83fb3e58d29..83397a1dda2 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -11,6 +11,7 @@ class CleanupContainerRepositoryWorker def perform(current_user_id, container_repository_id, params) @current_user = User.find_by_id(current_user_id) @container_repository = ContainerRepository.find_by_id(container_repository_id) + @params = params return unless valid? @@ -22,9 +23,15 @@ class CleanupContainerRepositoryWorker private def valid? + return true if run_by_container_expiration_policy? + current_user && container_repository && project end + def run_by_container_expiration_policy? + @params['container_expiration_policy'] && container_repository && project + end + def project container_repository&.project end diff --git a/app/workers/cluster_configure_istio_worker.rb b/app/workers/cluster_configure_istio_worker.rb new file mode 100644 index 00000000000..dfdd408f286 --- /dev/null +++ b/app/workers/cluster_configure_istio_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ClusterConfigureIstioWorker + include ApplicationWorker + include ClusterQueue + + worker_has_external_dependencies! + + def perform(cluster_id) + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + Clusters::Kubernetes::ConfigureIstioIngressService.new(cluster: cluster).execute + end + end +end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 62748808ff1..733156ab758 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -9,6 +9,7 @@ module ApplicationWorker include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker include WorkerAttributes + include WorkerContext included do set_queue diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb index 0683b229381..25ee4539cab 100644 --- a/app/workers/concerns/cronjob_queue.rb +++ b/app/workers/concerns/cronjob_queue.rb @@ -8,5 +8,6 @@ module CronjobQueue included do queue_namespace :cronjob sidekiq_options retry: false + worker_context project: nil, namespace: nil, user: nil end end diff --git a/app/workers/concerns/security_scans_queue.rb b/app/workers/concerns/security_scans_queue.rb new file mode 100644 index 00000000000..f731317bb37 --- /dev/null +++ b/app/workers/concerns/security_scans_queue.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +## +# Concern for setting Sidekiq settings for the various Secure product queues +# +module SecurityScansQueue + extend ActiveSupport::Concern + + included do + queue_namespace :security_scans + feature_category :static_application_security_testing + end +end diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb index 44dd6866fad..1796e2441f2 100644 --- a/app/workers/concerns/self_monitoring_project_worker.rb +++ b/app/workers/concerns/self_monitoring_project_worker.rb @@ -9,6 +9,7 @@ module SelfMonitoringProjectWorker # Other Functionality. Metrics seems to be the closest feature_category for # this worker. feature_category :metrics + weight 2 end LEASE_TIMEOUT = 15.minutes.to_i diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 506215ca9ed..babdb46bb85 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -7,6 +7,25 @@ module WorkerAttributes # `worker_resource_boundary` attribute VALID_RESOURCE_BOUNDARIES = [:memory, :cpu, :unknown].freeze + NAMESPACE_WEIGHTS = { + auto_devops: 2, + auto_merge: 3, + chaos: 2, + deployment: 3, + mail_scheduler: 2, + notifications: 2, + pipeline_cache: 3, + pipeline_creation: 4, + pipeline_default: 3, + pipeline_hooks: 2, + pipeline_processing: 5, + + # EE-specific + epics: 2, + incident_management: 2, + security_scans: 2 + }.stringify_keys.freeze + class_methods do def feature_category(value) raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned @@ -70,6 +89,16 @@ module WorkerAttributes worker_attributes[:resource_boundary] || :unknown end + def weight(value) + worker_attributes[:weight] = value + end + + def get_weight + worker_attributes[:weight] || + NAMESPACE_WEIGHTS[queue_namespace] || + 1 + end + protected # Returns a worker attribute declared on this class or its parent class. diff --git a/app/workers/concerns/worker_context.rb b/app/workers/concerns/worker_context.rb new file mode 100644 index 00000000000..f2ff3ecfb6b --- /dev/null +++ b/app/workers/concerns/worker_context.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module WorkerContext + extend ActiveSupport::Concern + + class_methods do + def worker_context(attributes) + @worker_context = Gitlab::ApplicationContext.new(attributes) + end + + def get_worker_context + @worker_context || superclass_context + end + + def bulk_perform_async_with_contexts(objects, arguments_proc:, context_proc:) + with_batch_contexts(objects, arguments_proc, context_proc) do |arguments| + bulk_perform_async(arguments) + end + end + + def bulk_perform_in_with_contexts(delay, objects, arguments_proc:, context_proc:) + with_batch_contexts(objects, arguments_proc, context_proc) do |arguments| + bulk_perform_in(delay, arguments) + end + end + + def context_for_arguments(args) + batch_context&.context_for(args) + end + + private + + BATCH_CONTEXT_KEY = "#{name}_batch_context" + + def batch_context + Thread.current[BATCH_CONTEXT_KEY] + end + + def batch_context=(value) + Thread.current[BATCH_CONTEXT_KEY] = value + end + + def with_batch_contexts(objects, arguments_proc, context_proc) + self.batch_context = Gitlab::BatchWorkerContext.new( + objects, + arguments_proc: arguments_proc, + context_proc: context_proc + ) + + yield(batch_context.arguments) + ensure + self.batch_context = nil + end + + def superclass_context + return unless superclass.include?(WorkerContext) + + superclass.get_worker_context + end + end + + def with_context(context, &block) + Gitlab::ApplicationContext.new(context).use { yield(**context) } + end +end diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index 595208230f6..e07a6546e2d 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -8,9 +8,11 @@ class ContainerExpirationPolicyWorker def perform ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy| - ContainerExpirationPolicyService.new( - container_expiration_policy.project, container_expiration_policy.project.owner - ).execute(container_expiration_policy) + with_context(project: container_expiration_policy.project, + user: container_expiration_policy.project.owner) do |project:, user:| + ContainerExpirationPolicyService.new(project, user) + .execute(container_expiration_policy) + end end end end diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index fc36a2adccd..027fea3e402 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -class CreateGpgSignatureWorker +class CreateCommitSignatureWorker include ApplicationWorker feature_category :source_code_management + weight 2 # rubocop: disable CodeReuse/ActiveRecord def perform(commit_shas, project_id) @@ -22,7 +23,12 @@ class CreateGpgSignatureWorker # This calculates and caches the signature in the database commits.each do |commit| - Gitlab::Gpg::Commit.new(commit).signature + case commit.signature_type + when :PGP + Gitlab::Gpg::Commit.new(commit).signature + when :X509 + Gitlab::X509::Commit.new(commit).signature + end rescue => e Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger end diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb index 027dbd2f101..e6fbf59d702 100644 --- a/app/workers/create_evidence_worker.rb +++ b/app/workers/create_evidence_worker.rb @@ -4,6 +4,7 @@ class CreateEvidenceWorker include ApplicationWorker feature_category :release_governance + weight 2 def perform(release_id) release = Release.find_by_id(release_id) diff --git a/app/workers/deployments/forward_deployment_worker.rb b/app/workers/deployments/forward_deployment_worker.rb new file mode 100644 index 00000000000..a25b8ca0478 --- /dev/null +++ b/app/workers/deployments/forward_deployment_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Deployments + class ForwardDeploymentWorker + include ApplicationWorker + + queue_namespace :deployment + feature_category :continuous_delivery + + def perform(deployment_id) + Deployments::OlderDeploymentsDropService.new(deployment_id).execute + end + end +end diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index b56bf4ed833..c2b1e642604 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -5,6 +5,7 @@ class EmailReceiverWorker feature_category :issue_tracking latency_sensitive_worker! + weight 2 def perform(raw) return unless Gitlab::IncomingEmail.enabled? diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index f523f5953e1..be66e2b1188 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -8,6 +8,7 @@ class EmailsOnPushWorker feature_category :source_code_management latency_sensitive_worker! worker_resource_boundary :cpu + weight 2 def perform(project_id, recipients, push_data, options = {}) options.symbolize_keys! diff --git a/app/workers/environments/auto_stop_cron_worker.rb b/app/workers/environments/auto_stop_cron_worker.rb new file mode 100644 index 00000000000..fdc9490453c --- /dev/null +++ b/app/workers/environments/auto_stop_cron_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Environments + class AutoStopCronWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :continuous_delivery + + def perform + return unless Feature.enabled?(:auto_stop_environments, default_enabled: true) + + AutoStopService.new.execute + end + end +end diff --git a/app/workers/error_tracking_issue_link_worker.rb b/app/workers/error_tracking_issue_link_worker.rb new file mode 100644 index 00000000000..b306ecc154b --- /dev/null +++ b/app/workers/error_tracking_issue_link_worker.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Creates a link in Sentry between a Sentry issue and a GitLab issue. +# If the link already exists, no changes will occur. +# If a link to a different GitLab issue exists, a new link +# will still be created, but will not be visible in Sentry +# until the prior link is deleted. +class ErrorTrackingIssueLinkWorker + include ApplicationWorker + include ExclusiveLeaseGuard + include Gitlab::Utils::StrongMemoize + + feature_category :error_tracking + worker_has_external_dependencies! + + LEASE_TIMEOUT = 15.minutes + + attr_reader :issue + + def perform(issue_id) + @issue = Issue.find_by_id(issue_id) + + return unless valid? + + try_obtain_lease do + logger.info("Linking Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id}") + + sentry_client.create_issue_link(integration_id, sentry_issue_id, issue) + rescue Sentry::Client::Error + logger.info("Failed to link Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id}") + end + end + + private + + def valid? + issue && error_tracking && sentry_issue_id + end + + def error_tracking + strong_memoize(:error_tracking) do + issue.project.error_tracking_setting + end + end + + def sentry_issue_id + strong_memoize(:sentry_issue_id) do + issue.sentry_issue.sentry_issue_identifier + end + end + + def sentry_client + error_tracking.sentry_client + end + + def integration_id + strong_memoize(:integration_id) do + repo&.integration_id + end + end + + def repo + sentry_client + .repos(organization_slug) + .find { |repo| repo.project_id == issue.project_id && repo.status == 'active' } + end + + def organization_slug + error_tracking.organization_slug + end + + def project_url + ::Gitlab::Routing.url_helpers.project_url(issue.project) + end + + def lease_key + "link_sentry_issue_#{sentry_issue_id}_gitlab_#{issue.id}" + end + + def lease_timeout + LEASE_TIMEOUT + end +end diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 383fd30e098..07f516a3390 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -2,7 +2,10 @@ class ExpireBuildArtifactsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :continuous_integration diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index ab57c59ffda..1d204e0a19e 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -11,7 +11,7 @@ class ExpirePipelineCacheWorker # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) pipeline = Ci::Pipeline.find_by(id: pipeline_id) - return unless pipeline + return unless pipeline&.cacheable? Ci::ExpirePipelineCacheService.new.execute(pipeline) end diff --git a/app/workers/gitlab/phabricator_import/base_worker.rb b/app/workers/gitlab/phabricator_import/base_worker.rb new file mode 100644 index 00000000000..faae71d4627 --- /dev/null +++ b/app/workers/gitlab/phabricator_import/base_worker.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# All workers within a Phabricator import should inherit from this worker and +# implement the `#import` method. The jobs should then be scheduled using the +# `.schedule` class method instead of `.perform_async` +# +# Doing this makes sure that only one job of that type is running at the same time +# for a certain project. This will avoid deadlocks. When a job is already running +# we'll wait for it for 10 times 5 seconds to restart. If the running job hasn't +# finished, by then, we'll retry in 30 seconds. +# +# It also makes sure that we keep the import state of the project up to date: +# - It keeps track of the jobs so we know how many jobs are running for the +# project +# - It refreshes the import jid, so it doesn't get cleaned up by the +# `StuckImportJobsWorker` +# - It marks the import as failed if a job failed to many times +# - It marks the import as finished when all remaining jobs are done +module Gitlab + module PhabricatorImport + class BaseWorker + include WorkerAttributes + include Gitlab::ExclusiveLeaseHelpers + + feature_category :importers + + class << self + def schedule(project_id, *args) + perform_async(project_id, *args) + add_job(project_id) + end + + def add_job(project_id) + worker_state(project_id).add_job + end + + def remove_job(project_id) + worker_state(project_id).remove_job + end + + def worker_state(project_id) + Gitlab::PhabricatorImport::WorkerState.new(project_id) + end + end + + def perform(project_id, *args) + in_lock("#{self.class.name.underscore}/#{project_id}/#{args}", ttl: 2.hours, sleep_sec: 5.seconds) do + project = Project.find_by_id(project_id) + next unless project + + # Bail if the import job already failed + next unless project.import_state&.in_progress? + + project.import_state.refresh_jid_expiration + + import(project, *args) + + # If this is the last running job, finish the import + project.after_import if self.class.worker_state(project_id).running_count < 2 + + self.class.remove_job(project_id) + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + # Reschedule a job if there was already a running one + # Running them at the same time could cause a deadlock updating the same + # resource + self.class.perform_in(30.seconds, project_id, *args) + end + + private + + def import(project, *args) + importer_class.new(project, *args).execute + end + + def importer_class + raise NotImplementedError, "Implement `#{__method__}` on #{self.class}" + end + end + end +end diff --git a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb new file mode 100644 index 00000000000..b5d9e80797b --- /dev/null +++ b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module Gitlab + module PhabricatorImport + class ImportTasksWorker < BaseWorker + include ApplicationWorker + include ProjectImportOptions # This marks the project as failed after too many tries + + def importer_class + Gitlab::PhabricatorImport::Issues::Importer + end + end + end +end diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index 57e64570c09..bd2225e6d7c 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -6,6 +6,7 @@ class GitlabShellWorker feature_category :source_code_management latency_sensitive_worker! + weight 2 def perform(action, *arg) Gitlab::GitalyClient::NamespaceService.allow do diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index ad8302a844a..bf0dc0fdd59 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -4,7 +4,10 @@ class GitlabUsagePingWorker LEASE_TIMEOUT = 86400 include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category_not_owned! diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb index 51dbdc95661..a2d34e8c8bf 100644 --- a/app/workers/group_export_worker.rb +++ b/app/workers/group_export_worker.rb @@ -4,11 +4,11 @@ class GroupExportWorker include ApplicationWorker include ExceptionBacktrace - feature_category :source_code_management + feature_category :importers def perform(current_user_id, group_id, params = {}) current_user = User.find(current_user_id) - group = Group.find(group_id) + group = Group.find(group_id) ::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute end diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb new file mode 100644 index 00000000000..f283eab5814 --- /dev/null +++ b/app/workers/group_import_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class GroupImportWorker + include ApplicationWorker + include ExceptionBacktrace + + feature_category :importers + + def perform(user_id, group_id) + current_user = User.find(user_id) + group = Group.find(group_id) + + ::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute + end +end diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index 07c29d40b54..ae236fa1fcd 100644 --- a/app/workers/import_export_project_cleanup_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -2,7 +2,10 @@ class ImportExportProjectCleanupWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :importers diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index d2733dc5f56..7c5584146ca 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -5,6 +5,7 @@ class ImportIssuesCsvWorker feature_category :issue_tracking worker_resource_boundary :cpu + weight 2 sidekiq_retries_exhausted do |job| Upload.find(job['args'][2]).destroy diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb new file mode 100644 index 00000000000..f3d5bc5c66b --- /dev/null +++ b/app/workers/incident_management/process_alert_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module IncidentManagement + class ProcessAlertWorker + include ApplicationWorker + + queue_namespace :incident_management + feature_category :incident_management + + def perform(project_id, alert) + project = find_project(project_id) + return unless project + + create_issue(project, alert) + end + + private + + def find_project(project_id) + Project.find_by_id(project_id) + end + + def create_issue(project, alert) + IncidentManagement::CreateIssueService + .new(project, alert) + .execute + end + end +end diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index 573efdf9fb1..e1c2eefbf0f 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -4,6 +4,7 @@ class InvalidGpgSignatureUpdateWorker include ApplicationWorker feature_category :source_code_management + weight 2 # rubocop: disable CodeReuse/ActiveRecord def perform(gpg_key_id) diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb index d4d47659ef0..59027907284 100644 --- a/app/workers/issue_due_scheduler_worker.rb +++ b/app/workers/issue_due_scheduler_worker.rb @@ -2,7 +2,7 @@ class IssueDueSchedulerWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :issue_tracking @@ -10,7 +10,7 @@ class IssueDueSchedulerWorker def perform project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] } - MailScheduler::IssueDueWorker.bulk_perform_async(project_ids) + MailScheduler::IssueDueWorker.bulk_perform_async(project_ids) # rubocop:disable Scalability/BulkPerformWithContext end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 4130ce25878..ec659e39b24 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -26,49 +26,26 @@ module MailScheduler end def self.perform_async(*args) - super(*Arguments.serialize(args)) + super(*ActiveJob::Arguments.serialize(args)) end private - # If an argument is in the ActiveJob::Arguments::TYPE_WHITELIST list, + # This is copied over from https://github.com/rails/rails/blob/v6.0.1/activejob/lib/active_job/arguments.rb#L50 + # because it is declared as a private constant + PERMITTED_TYPES = [NilClass, String, Integer, Float, BigDecimal, TrueClass, FalseClass].freeze + + private_constant :PERMITTED_TYPES + + # If an argument is in the PERMITTED_TYPES list, # it means the argument cannot be deserialized. # Which means there's something wrong with our code. def check_arguments!(args) args.each do |arg| - if arg.class.in?(ActiveJob::Arguments::TYPE_WHITELIST) + if arg.class.in?(PERMITTED_TYPES) raise(ArgumentError, "Argument `#{arg}` cannot be deserialized because of its type") end end end - - # Permit ActionController::Parameters for serializable Hash - # - # Port of - # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663 - module Arguments - include ActiveJob::Arguments - extend self - - private - - def serialize_argument(argument) - case argument - when -> (arg) { arg.respond_to?(:permitted?) } - serialize_hash(argument.to_h).tap do |result| - result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) - end - else - super - end - end - end - - # Make sure we remove this patch starting with Rails 6.0. - if Rails.version.start_with?('6.0') - raise <<~MSG - Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again. - MSG - end end end diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb new file mode 100644 index 00000000000..ed35284b66c --- /dev/null +++ b/app/workers/merge_request_mergeability_check_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class MergeRequestMergeabilityCheckWorker + include ApplicationWorker + + feature_category :source_code_management + + def perform(merge_request_id) + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.error("Failed to find merge request with ID: #{merge_request_id}") + return + end + + result = + ::MergeRequests::MergeabilityCheckService + .new(merge_request) + .execute(recheck: false, retry_lease: false) + + logger.error("Failed to check mergeability of merge request (#{merge_request_id}): #{result.message}") if result.error? + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index ed88c57e8d4..48bc205113f 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -5,6 +5,7 @@ class MergeWorker feature_category :source_code_management latency_sensitive_worker! + weight 5 def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb index 9a5f533fe9a..aeb5aa37a10 100644 --- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -3,7 +3,7 @@ module Namespaces class PruneAggregationSchedulesWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management worker_resource_boundary :cpu diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index af9ca332d3c..d696165b447 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -7,6 +7,7 @@ class NewIssueWorker feature_category :issue_tracking latency_sensitive_worker! worker_resource_boundary :cpu + weight 2 def perform(issue_id, user_id) return unless objects_found?(issue_id, user_id) diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index aa3f85c157b..e31ddae1f13 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -7,6 +7,7 @@ class NewMergeRequestWorker feature_category :source_code_management latency_sensitive_worker! worker_resource_boundary :cpu + weight 2 def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 2a5988a7e32..b446e376007 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -6,6 +6,7 @@ class NewNoteWorker feature_category :issue_tracking latency_sensitive_worker! worker_resource_boundary :cpu + weight 2 # Keep extra parameter to preserve backwards compatibility with # old `NewNoteWorker` jobs (can remove later) diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb index a3a882f9343..edfdb2d7aff 100644 --- a/app/workers/new_release_worker.rb +++ b/app/workers/new_release_worker.rb @@ -5,6 +5,7 @@ class NewReleaseWorker queue_namespace :notifications feature_category :release_orchestration + weight 2 def perform(release_id) release = Release.preloaded.find_by_id(release_id) diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index 07ecde55922..1c96dd6ad8c 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -8,8 +8,8 @@ class PagesDomainRemovalCronWorker worker_resource_boundary :cpu def perform - PagesDomain.for_removal.find_each do |domain| - domain.destroy! + PagesDomain.for_removal.with_logging_info.find_each do |domain| + with_context(project: domain.project) { domain.destroy! } rescue => e Gitlab::ErrorTracking.track_exception(e) end diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb index f7a243e9b3b..c1201b935d1 100644 --- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb @@ -9,8 +9,10 @@ class PagesDomainSslRenewalCronWorker def perform return unless ::Gitlab::LetsEncrypt.enabled? - PagesDomain.need_auto_ssl_renewal.find_each do |domain| - PagesDomainSslRenewalWorker.perform_async(domain.id) + PagesDomain.need_auto_ssl_renewal.with_logging_info.find_each do |domain| + with_context(project: domain.project) do + PagesDomainSslRenewalWorker.perform_async(domain.id) + end end end end diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb index bb3a7fede9a..b06aa65a8e5 100644 --- a/app/workers/pages_domain_verification_cron_worker.rb +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -9,8 +9,10 @@ class PagesDomainVerificationCronWorker def perform return if Gitlab::Database.read_only? - PagesDomain.needs_verification.find_each do |domain| - PagesDomainVerificationWorker.perform_async(domain.id) + PagesDomain.needs_verification.with_logging_info.find_each do |domain| + with_context(project: domain.project) do + PagesDomainVerificationWorker.perform_async(domain.id) + end end end end diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb index f28109c4583..84f7ce9d5d7 100644 --- a/app/workers/personal_access_tokens/expiring_worker.rb +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -12,11 +12,13 @@ module PersonalAccessTokens limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date User.with_expiring_and_not_notified_personal_access_tokens(limit_date).find_each do |user| - notification_service.access_token_about_to_expire(user) + with_context(user: user) do + notification_service.access_token_about_to_expire(user) - Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger + Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger - user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true) + user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true) + end end end end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 19c3c5fcc2f..8b326b9dbb6 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -10,7 +10,9 @@ class PipelineScheduleWorker def perform Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| schedules.each do |schedule| - Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) + with_context(project: schedule.project, user: schedule.owner) do + Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) + end end end end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 334a98a0017..d5038f1152b 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -6,6 +6,7 @@ class PostReceive feature_category :source_code_management latency_sensitive_worker! worker_resource_boundary :cpu + weight 5 def perform(gl_repository, identifier, changes, push_options = {}) project, repo_type = Gitlab::GlRepository.parse(gl_repository) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 36af51d859e..ca2896946c9 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -12,6 +12,7 @@ class ProcessCommitWorker feature_category :source_code_management latency_sensitive_worker! + weight 3 # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index 11f3fed82cd..4d2cc3cd32d 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -5,7 +5,7 @@ class ProjectExportWorker include ExceptionBacktrace sidekiq_options retry: 3 - feature_category :source_code_management + feature_category :importers worker_resource_boundary :memory def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb index f421e8dbf59..835c51ec846 100644 --- a/app/workers/prune_old_events_worker.rb +++ b/app/workers/prune_old_events_worker.rb @@ -2,22 +2,19 @@ class PruneOldEventsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category_not_owned! - # rubocop: disable CodeReuse/ActiveRecord + DELETE_LIMIT = 10_000 + def perform # Contribution calendar shows maximum 12 months of events, we retain 3 years for data integrity. - # Double nested query is used because MySQL doesn't allow DELETE subqueries on the same table. - Event.unscoped.where( - '(id IN (SELECT id FROM (?) ids_to_remove))', - Event.unscoped.where( - 'created_at < ?', - (3.years + 1.day).ago) - .select(:id) - .limit(10_000)) - .delete_all + cutoff_date = (3.years + 1.day).ago + + Event.unscoped.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb index 8e48b45fc34..dd4f16a69da 100644 --- a/app/workers/prune_web_hook_logs_worker.rb +++ b/app/workers/prune_web_hook_logs_worker.rb @@ -4,27 +4,19 @@ # table. class PruneWebHookLogsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :integrations # The maximum number of rows to remove in a single job. DELETE_LIMIT = 50_000 - # rubocop: disable CodeReuse/ActiveRecord def perform - # MySQL doesn't allow "DELETE FROM ... WHERE id IN ( ... )" if the inner - # query refers to the same table. To work around this we wrap the IN body in - # another sub query. - WebHookLog - .where( - 'id IN (SELECT id FROM (?) ids_to_remove)', - WebHookLog - .select(:id) - .where('created_at < ?', 90.days.ago.beginning_of_day) - .limit(DELETE_LIMIT) - ) - .delete_all + cutoff_date = 90.days.ago.beginning_of_day + + WebHookLog.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index f3a83e0e8d4..6f82ad83137 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -25,5 +25,7 @@ class ReactiveCachingWorker .reactive_cache_worker_finder .call(id, *args) .try(:exclusively_update_reactive_cache!, *args) + rescue ReactiveCaching::ExceededReactiveCacheLimit => e + Gitlab::ErrorTracking.track_exception(e) end end diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index fd182125c07..ddf5c31a1c2 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -6,6 +6,7 @@ class RebaseWorker include ApplicationWorker feature_category :source_code_management + weight 2 def perform(merge_request_id, current_user_id, skip_ci = false) current_user = User.find(current_user_id) diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb index 8bc19230caf..706131d4e4b 100644 --- a/app/workers/remote_mirror_notification_worker.rb +++ b/app/workers/remote_mirror_notification_worker.rb @@ -4,6 +4,7 @@ class RemoteMirrorNotificationWorker include ApplicationWorker feature_category :source_code_management + weight 2 def perform(remote_mirror_id) remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index a43e6fd11d5..db35dfb3ca8 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -2,7 +2,7 @@ class RemoveExpiredGroupLinksWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :authentication_and_authorization diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index bf209fcec9f..278adee98e9 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -2,7 +2,7 @@ class RemoveExpiredMembersWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :authentication_and_authorization worker_resource_boundary :cpu diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb index 7f2c23f4685..5e3998f3915 100644 --- a/app/workers/remove_unreferenced_lfs_objects_worker.rb +++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb @@ -2,9 +2,12 @@ class RemoveUnreferencedLfsObjectsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext - feature_category :source_code_management + feature_category :git_lfs def perform LfsObject.destroy_unreferenced diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb index ebc83c1b17a..76e08a80c15 100644 --- a/app/workers/repository_archive_cache_worker.rb +++ b/app/workers/repository_archive_cache_worker.rb @@ -2,7 +2,10 @@ class RepositoryArchiveCacheWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb index d2bd5f9b967..f68be8832eb 100644 --- a/app/workers/repository_check/dispatch_worker.rb +++ b/app/workers/repository_check/dispatch_worker.rb @@ -3,7 +3,10 @@ module RepositoryCheck class DispatchWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext include ::EachShardWorker include ExclusiveLeaseGuard diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 0adf745c7ac..ba141f808a7 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -29,7 +29,15 @@ class RepositoryForkWorker result = gitlab_shell.fork_repository(source_project, target_project) - raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result + if result + link_lfs_objects(source_project, target_project) + else + raise_fork_failure( + source_project, + target_project, + 'Failed to create fork repository' + ) + end target_project.after_import end @@ -40,4 +48,20 @@ class RepositoryForkWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") # rubocop:disable Gitlab/RailsLogger false end + + def link_lfs_objects(source_project, target_project) + Projects::LfsPointers::LfsLinkService + .new(target_project) + .execute(source_project.lfs_objects_oids) + rescue Projects::LfsPointers::LfsLinkService::TooManyOidsError + raise_fork_failure( + source_project, + target_project, + 'Source project has too many LFS objects' + ) + end + + def raise_fork_failure(source_project, target_project, reason) + raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}: #{reason}" + end end diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb index 6ab020afb10..b711cb99082 100644 --- a/app/workers/requests_profiles_worker.rb +++ b/app/workers/requests_profiles_worker.rb @@ -2,7 +2,10 @@ class RequestsProfilesWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb index 8abb5922b54..0e3c62cf282 100644 --- a/app/workers/schedule_migrate_external_diffs_worker.rb +++ b/app/workers/schedule_migrate_external_diffs_worker.rb @@ -2,7 +2,12 @@ class ScheduleMigrateExternalDiffsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext: + # This schedules the `MigrateExternalDiffsWorker` + # issue for adding context: https://gitlab.com/gitlab-org/gitlab/issues/202100 include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext: + include Gitlab::ExclusiveLeaseHelpers feature_category :source_code_management diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index d08cea9e494..6e4ffa36854 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -56,13 +56,13 @@ class StuckCiJobsWorker loop do jobs = Ci::Build.where(status: status) .where(condition, timeout.ago) - .includes(:tags, :runner, project: :namespace) + .includes(:tags, :runner, project: [:namespace, :route]) .limit(100) .to_a break if jobs.empty? jobs.each do |job| - yield(job) + with_context(project: job.project) { yield(job) } end end end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index d9a9a613ca9..c9675417aa4 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -2,7 +2,11 @@ class StuckImportJobsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker updates several import states inline and does not schedule + # other jobs. So no context needed include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext feature_category :importers worker_resource_boundary :cpu diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 024863ab530..9214ae038a8 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -2,7 +2,7 @@ class StuckMergeJobsWorker include ApplicationWorker - include CronjobQueue + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index 4c8ee1ee425..208d8b3b9b5 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -2,7 +2,11 @@ class TrendingProjectsWorker include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb index 8b0952528fa..e363b33f1b9 100644 --- a/app/workers/update_external_pull_requests_worker.rb +++ b/app/workers/update_external_pull_requests_worker.rb @@ -4,6 +4,7 @@ class UpdateExternalPullRequestsWorker include ApplicationWorker feature_category :source_code_management + weight 3 def perform(project_id, user_id, ref) project = Project.find_by_id(project_id) diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index acb95353983..ec9739e8a11 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -6,6 +6,7 @@ class UpdateMergeRequestsWorker feature_category :source_code_management latency_sensitive_worker! worker_resource_boundary :cpu + weight 3 LOG_TIME_THRESHOLD = 90 # seconds |