From c52b72f5772d52e9fc85bd9f4e8b8497a6278c37 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 8 Jul 2020 12:09:33 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- app/assets/javascripts/api.js | 15 +- .../boards/components/board_content.vue | 2 +- .../javascripts/boards/components/board_form.vue | 5 - .../boards/components/boards_selector.vue | 5 - .../javascripts/boards/components/modal/header.vue | 4 - .../javascripts/boards/components/modal/index.vue | 10 +- .../boards/mount_multiple_boards_switcher.js | 2 +- app/assets/javascripts/milestone_select.js | 151 ++++++++++------ .../static_site_editor/components/edit_area.vue | 19 +- .../javascripts/static_site_editor/constants.js | 2 + .../graphql/resolvers/submit_content_changes.js | 4 +- .../static_site_editor/image_repository.js | 20 +++ .../javascripts/static_site_editor/pages/home.vue | 7 +- .../static_site_editor/services/image_service.js | 9 + .../services/submit_content_changes.js | 32 +++- .../components/markdown/suggestion_diff.vue | 1 + .../components/markdown/suggestion_diff_header.vue | 9 +- .../modals/add_image/add_image_modal.vue | 12 +- .../rich_content_editor/rich_content_editor.vue | 13 +- .../rich_content_editor/services/image_service.js | 2 - .../stylesheets/framework/system_messages.scss | 3 +- app/assets/stylesheets/pages/boards.scss | 26 +-- app/assets/stylesheets/utilities.scss | 9 - app/graphql/resolvers/milestone_resolver.rb | 6 +- app/helpers/clusters_helper.rb | 5 +- app/models/clusters/cluster.rb | 8 + app/models/concerns/deployment_platform.rb | 22 ++- app/models/packages.rb | 6 + app/models/packages/build_info.rb | 6 + app/models/packages/composer/metadatum.rb | 14 ++ app/models/packages/conan.rb | 8 + app/models/packages/conan/file_metadatum.rb | 32 ++++ app/models/packages/conan/metadatum.rb | 41 +++++ app/models/packages/dependency.rb | 47 +++++ app/models/packages/dependency_link.rb | 19 ++ app/models/packages/go/module.rb | 93 ++++++++++ app/models/packages/go/module_version.rb | 115 ++++++++++++ app/models/packages/maven.rb | 8 + app/models/packages/maven/metadatum.rb | 28 +++ app/models/packages/nuget.rb | 8 + .../packages/nuget/dependency_link_metadatum.rb | 19 ++ app/models/packages/nuget/metadatum.rb | 27 +++ app/models/packages/package.rb | 195 +++++++++++++++++++++ app/models/packages/package_file.rb | 56 ++++++ app/models/packages/pypi.rb | 8 + app/models/packages/pypi/metadatum.rb | 19 ++ app/models/packages/sem_ver.rb | 54 ++++++ app/models/packages/tag.rb | 18 ++ app/models/project.rb | 24 ++- app/models/prometheus_metric.rb | 1 + app/presenters/clusterable_presenter.rb | 14 +- app/services/clusters/create_service.rb | 11 -- .../metrics/dashboard/clone_dashboard_service.rb | 46 +++-- .../metrics/dashboard/cluster_dashboard_service.rb | 31 ++++ .../dashboard/cluster_metrics_embed_service.rb | 37 ++++ .../packages/conan/create_package_file_service.rb | 31 ++++ .../packages/conan/create_package_service.rb | 19 ++ app/services/packages/conan/search_service.rb | 58 ++++++ app/services/packages/create_dependency_service.rb | 82 +++++++++ .../packages/create_package_file_service.rb | 22 +++ .../packages/maven/create_package_service.rb | 28 +++ .../maven/find_or_create_package_service.rb | 41 +++++ .../packages/npm/create_package_service.rb | 91 ++++++++++ app/services/packages/npm/create_tag_service.rb | 34 ++++ .../packages/nuget/create_dependency_service.rb | 71 ++++++++ .../packages/nuget/create_package_service.rb | 23 +++ .../packages/nuget/metadata_extraction_service.rb | 106 +++++++++++ app/services/packages/nuget/search_service.rb | 101 +++++++++++ .../packages/nuget/sync_metadatum_service.rb | 50 ++++++ .../nuget/update_package_from_metadata_service.rb | 125 +++++++++++++ .../packages/pypi/create_package_service.rb | 40 +++++ app/services/packages/remove_tag_service.rb | 16 ++ app/services/packages/update_tags_service.rb | 41 +++++ app/uploaders/packages/package_file_uploader.rb | 30 ++++ .../clusters/_multiple_clusters_message.html.haml | 6 + app/views/clusters/clusters/_sidebar.html.haml | 2 +- .../_confirmation_instructions_secondary.html.haml | 2 +- .../_confirmation_instructions_secondary.text.erb | 2 +- .../boards/components/sidebar/_milestone.html.haml | 3 +- app/views/shared/issuable/_sidebar.html.haml | 3 +- 80 files changed, 2215 insertions(+), 200 deletions(-) create mode 100644 app/assets/javascripts/static_site_editor/image_repository.js create mode 100644 app/assets/javascripts/static_site_editor/services/image_service.js delete mode 100644 app/assets/javascripts/vue_shared/components/rich_content_editor/services/image_service.js create mode 100644 app/models/packages.rb create mode 100644 app/models/packages/build_info.rb create mode 100644 app/models/packages/composer/metadatum.rb create mode 100644 app/models/packages/conan.rb create mode 100644 app/models/packages/conan/file_metadatum.rb create mode 100644 app/models/packages/conan/metadatum.rb create mode 100644 app/models/packages/dependency.rb create mode 100644 app/models/packages/dependency_link.rb create mode 100644 app/models/packages/go/module.rb create mode 100644 app/models/packages/go/module_version.rb create mode 100644 app/models/packages/maven.rb create mode 100644 app/models/packages/maven/metadatum.rb create mode 100644 app/models/packages/nuget.rb create mode 100644 app/models/packages/nuget/dependency_link_metadatum.rb create mode 100644 app/models/packages/nuget/metadatum.rb create mode 100644 app/models/packages/package.rb create mode 100644 app/models/packages/package_file.rb create mode 100644 app/models/packages/pypi.rb create mode 100644 app/models/packages/pypi/metadatum.rb create mode 100644 app/models/packages/sem_ver.rb create mode 100644 app/models/packages/tag.rb create mode 100644 app/services/metrics/dashboard/cluster_dashboard_service.rb create mode 100644 app/services/metrics/dashboard/cluster_metrics_embed_service.rb create mode 100644 app/services/packages/conan/create_package_file_service.rb create mode 100644 app/services/packages/conan/create_package_service.rb create mode 100644 app/services/packages/conan/search_service.rb create mode 100644 app/services/packages/create_dependency_service.rb create mode 100644 app/services/packages/create_package_file_service.rb create mode 100644 app/services/packages/maven/create_package_service.rb create mode 100644 app/services/packages/maven/find_or_create_package_service.rb create mode 100644 app/services/packages/npm/create_package_service.rb create mode 100644 app/services/packages/npm/create_tag_service.rb create mode 100644 app/services/packages/nuget/create_dependency_service.rb create mode 100644 app/services/packages/nuget/create_package_service.rb create mode 100644 app/services/packages/nuget/metadata_extraction_service.rb create mode 100644 app/services/packages/nuget/search_service.rb create mode 100644 app/services/packages/nuget/sync_metadatum_service.rb create mode 100644 app/services/packages/nuget/update_package_from_metadata_service.rb create mode 100644 app/services/packages/pypi/create_package_service.rb create mode 100644 app/services/packages/remove_tag_service.rb create mode 100644 app/services/packages/update_tags_service.rb create mode 100644 app/uploaders/packages/package_file_uploader.rb create mode 100644 app/views/clusters/clusters/_multiple_clusters_message.html.haml (limited to 'app') diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 94d155840ea..460611356c0 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -9,6 +9,7 @@ const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', groupMembersPath: '/api/:version/groups/:id/members', + groupMilestonesPath: '/api/:version/groups/:id/milestones', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', @@ -98,6 +99,14 @@ const Api = { return axios.get(url).then(({ data }) => data); }, + groupMilestones(groupId, params = {}) { + const url = Api.buildUrl(Api.groupMilestonesPath).replace(':id', encodeURIComponent(groupId)); + + return axios.get(url, { + params, + }); + }, + // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); @@ -262,10 +271,12 @@ const Api = { }); }, - projectMilestones(id) { + projectMilestones(id, params = {}) { const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params, + }); }, mergeRequests(params = {}) { diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index f0497ea0b64..6d0235aeaae 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -54,7 +54,7 @@ export default {
- + { hasMissingBoards: parseBoolean(dataset.hasMissingBoards), canAdminBoard: parseBoolean(dataset.canAdminBoard), multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), - projectId: Number(dataset.projectId), + projectId: dataset.projectId ? Number(dataset.projectId) : 0, groupId: Number(dataset.groupId), scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), weights: JSON.parse(dataset.weights), diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index caa45184bfc..8213f057b0b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -4,10 +4,11 @@ import $ from 'jquery'; import { template, escape } from 'lodash'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import '~/gl_dropdown'; +import Api from '~/api'; import axios from './lib/utils/axios_utils'; -import { timeFor } from './lib/utils/datetime_utility'; +import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility'; import ModalStore from './boards/stores/modal_store'; import boardsStore, { boardStoreIssueSet, @@ -34,10 +35,10 @@ export default class MilestoneSelect { $els.each((i, dropdown) => { let milestoneLinkNoneTemplate, milestoneLinkTemplate, + milestoneExpiredLinkTemplate, selectedMilestone, selectedMilestoneDefault; const $dropdown = $(dropdown); - const milestonesUrl = $dropdown.data('milestones'); const issueUpdateURL = $dropdown.data('issueUpdate'); const showNo = $dropdown.data('showNo'); const showAny = $dropdown.data('showAny'); @@ -63,58 +64,101 @@ export default class MilestoneSelect { milestoneLinkTemplate = template( '<%- title %>', ); + milestoneExpiredLinkTemplate = template( + '<%- title %> (Past due)', + ); milestoneLinkNoneTemplate = `${__('None')}`; } return $dropdown.glDropdown({ showMenuAbove, - data: (term, callback) => - axios.get(milestonesUrl).then(({ data }) => { - const extraOptions = []; - if (showAny) { - extraOptions.push({ - id: null, - name: null, - title: __('Any milestone'), - }); - } - if (showNo) { - extraOptions.push({ - id: -1, - name: __('No milestone'), - title: __('No milestone'), - }); - } - if (showUpcoming) { - extraOptions.push({ - id: -2, - name: '#upcoming', - title: __('Upcoming'), - }); - } - if (showStarted) { - extraOptions.push({ - id: -3, - name: '#started', - title: __('Started'), - }); - } - if (extraOptions.length) { - extraOptions.push({ type: 'divider' }); - } + data: (term, callback) => { + let contextId = $dropdown.get(0).dataset.projectId; + let getMilestones = Api.projectMilestones; - callback(extraOptions.concat(data)); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`).addClass('is-active'); - }), - renderRow: milestone => ` -
  • + if (!contextId) { + contextId = $dropdown.get(0).dataset.groupId; + getMilestones = Api.groupMilestones; + } + + // We don't use $.data() as it caches initial value and never updates! + return getMilestones(contextId, { state: 'active' }) + .then(({ data }) => + data + .map(m => ({ + ...m, + // Public API includes `title` instead of `name`. + name: m.title, + })) + .sort((mA, mB) => { + // Move all expired milestones to the bottom. + if (mA.expired) { + return 1; + } + if (mB.expired) { + return -1; + } + return 0; + }), + ) + .then(data => { + const extraOptions = []; + if (showAny) { + extraOptions.push({ + id: null, + name: null, + title: __('Any milestone'), + }); + } + if (showNo) { + extraOptions.push({ + id: -1, + name: __('No milestone'), + title: __('No milestone'), + }); + } + if (showUpcoming) { + extraOptions.push({ + id: -2, + name: '#upcoming', + title: __('Upcoming'), + }); + } + if (showStarted) { + extraOptions.push({ + id: -3, + name: '#started', + title: __('Started'), + }); + } + if (extraOptions.length) { + extraOptions.push({ type: 'divider' }); + } + + callback(extraOptions.concat(data)); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); + }); + }, + renderRow: milestone => { + const milestoneName = milestone.title || milestone.name; + let milestoneDisplayName = escape(milestoneName); + + if (milestone.expired) { + milestoneDisplayName = sprintf(__('%{milestone} (expired)'), { + milestone: milestoneDisplayName, + }); + } + + return ` +
  • - ${escape(milestone.title)} + ${milestoneDisplayName}
  • - `, + `; + }, filterable: true, search: { fields: ['title'], @@ -149,7 +193,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: clickEvent => { @@ -237,7 +281,16 @@ export default class MilestoneSelect { if (data.milestone != null) { data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; - $value.html(milestoneLinkTemplate(data.milestone)); + $value.html( + data.milestone.expired + ? milestoneExpiredLinkTemplate({ + ...data.milestone, + remaining: sprintf(__('%{due_date} (Past due)'), { + due_date: dateInWords(parsePikadayDate(data.milestone.due_date)), + }), + }) + : milestoneLinkTemplate(data.milestone), + ); return $sidebarCollapsedValue .attr( 'data-original-title', diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index b052c211542..84a16f327d9 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -5,6 +5,8 @@ import EditHeader from './edit_header.vue'; import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; +import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; +import imageRepository from '../image_repository'; export default { components: { @@ -31,6 +33,12 @@ export default { required: false, default: '', }, + imageRoot: { + type: String, + required: false, + default: DEFAULT_IMAGE_UPLOAD_PATH, + validator: prop => prop.endsWith('/'), + }, }, data() { return { @@ -40,6 +48,7 @@ export default { isModified: false, }; }, + imageRepository: imageRepository(), computed: { editableContent() { return this.parsedSource.content(this.isWysiwygMode); @@ -57,8 +66,14 @@ export default { this.editorMode = mode; this.$refs.editor.resetInitialValue(this.editableContent); }, + onUploadImage({ file, imageUrl }) { + this.$options.imageRepository.add(file, imageUrl); + }, onSubmit() { - this.$emit('submit', { content: this.parsedSource.content() }); + this.$emit('submit', { + content: this.parsedSource.content(), + images: this.$options.imageRepository.getAll(), + }); }, }, }; @@ -70,9 +85,11 @@ export default { ref="editor" :content="editableContent" :initial-edit-type="editorMode" + :image-root="imageRoot" class="mb-9 h-100" @modeChange="onModeChange" @input="onInputChange" + @uploadImage="onUploadImage" /> { - return submitContentChanges({ projectId, username, sourcePath, content }).then( + return submitContentChanges({ projectId, username, sourcePath, content, images }).then( savedContentMeta => { cache.writeQuery({ query: savedContentMetaQuery, diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js new file mode 100644 index 00000000000..541d581bda8 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; +import Flash from '~/flash'; +import { getBinary } from './services/image_service'; + +const imageRepository = () => { + const images = new Map(); + const flash = message => new Flash(message); + + const add = (file, url) => { + getBinary(file) + .then(content => images.set(url, content)) + .catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); + }; + + const getAll = () => images; + + return { add, getAll }; +}; + +export default imageRepository; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index a1314c8a478..156b815e07a 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -67,11 +67,11 @@ export default { onDismissError() { this.submitChangesError = null; }, - onSubmit({ content }) { + onSubmit({ content, images }) { this.content = content; - this.submitChanges(); + this.submitChanges(images); }, - submitChanges() { + submitChanges(images) { this.isSavingChanges = true; this.$apollo @@ -83,6 +83,7 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, + images, }, }, }) diff --git a/app/assets/javascripts/static_site_editor/services/image_service.js b/app/assets/javascripts/static_site_editor/services/image_service.js new file mode 100644 index 00000000000..edc69d0579a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/image_service.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const getBinary = file => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = error => reject(error); + }); +}; diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index fce7c1f918f..da62d3fa4fc 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -21,7 +21,32 @@ const createBranch = (projectId, branch) => throw new Error(SUBMIT_CHANGES_BRANCH_ERROR); }); -const commitContent = (projectId, message, branch, sourcePath, content) => { +const createImageActions = (images, markdown) => { + const actions = []; + + if (!markdown) { + return actions; + } + + images.forEach((imageContent, filePath) => { + const imageExistsInMarkdown = path => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![]() + + if (imageExistsInMarkdown(filePath).test(markdown)) { + actions.push( + convertObjectPropsToSnakeCase({ + encoding: 'base64', + action: 'create', + content: imageContent, + filePath, + }), + ); + } + }); + + return actions; +}; + +const commitContent = (projectId, message, branch, sourcePath, content, images) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); return Api.commitMultiple( @@ -35,6 +60,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => { filePath: sourcePath, content, }), + ...createImageActions(images, content), ], }), ).catch(() => { @@ -62,7 +88,7 @@ const createMergeRequest = ( }); }; -const submitContentChanges = ({ username, projectId, sourcePath, content }) => { +const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => { const branch = generateBranchName(username); const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { sourcePath, @@ -73,7 +99,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => { .then(({ data: { web_url: url } }) => { Object.assign(meta, { branch: { label: branch, url } }); - return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content); + return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images); }) .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 6dac448d5de..13c42d35b04 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -68,6 +68,7 @@ export default { :is-applying-batch="suggestion.is_applying_batch" :batch-suggestions-count="batchSuggestionsCount" :help-page-path="helpPagePath" + :inapplicable-reason="suggestion.inapplicable_reason" @apply="applySuggestion" @applyBatch="applySuggestionBatch" @addToBatch="addSuggestionToBatch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 54e837882ce..4de80e9b4c2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -38,6 +38,11 @@ export default { type: String, required: true, }, + inapplicableReason: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -52,9 +57,7 @@ export default { return this.isApplyingSingle || this.isApplyingBatch; }, tooltipMessage() { - return this.canApply - ? __('This also resolves this thread') - : __("Can't apply as this line has changed or the suggestion already matches its content."); + return this.canApply ? __('This also resolves this thread') : this.inapplicableReason; }, isDisableButton() { return this.isApplying || !this.canApply; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue index dce5d1778b3..0a444b2295d 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue @@ -16,8 +16,15 @@ export default { GlTab, }, mixins: [glFeatureFlagMixin()], + props: { + imageRoot: { + type: String, + required: true, + }, + }, data() { return { + file: null, urlError: null, imageUrl: null, description: null, @@ -38,6 +45,7 @@ export default { }, methods: { show() { + this.file = null; this.urlError = null; this.imageUrl = null; this.description = null; @@ -66,7 +74,9 @@ export default { return; } - this.$emit('addImage', { file, altText: altText || file.name }); + const imageUrl = `${this.imageRoot}${file.name}`; + + this.$emit('addImage', { imageUrl, file, altText: altText || file.name }); }, submitURL(event) { if (!this.validateUrl()) { diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index a32114b6a29..193310d1dc9 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -19,8 +19,6 @@ import { getMarkdown, } from './services/editor_service'; -import { getUrl } from './services/image_service'; - export default { components: { ToastEditor: () => @@ -54,6 +52,11 @@ export default { required: false, default: EDITOR_PREVIEW_STYLE, }, + imageRoot: { + type: String, + required: true, + validator: prop => prop.endsWith('/'), + }, }, data() { return { @@ -104,10 +107,8 @@ export default { const image = { imageUrl, altText }; if (file) { - image.imageUrl = getUrl(file); - // TODO - persist images locally (local image repository) + this.$emit('uploadImage', { file, imageUrl }); // TODO - ensure that the actual repo URL for the image is used in Markdown mode - // TODO - upload images to the project repository (on submit) } addImage(this.editorInstance, image); @@ -130,6 +131,6 @@ export default { @change="onContentChanged" @load="onLoad" /> - +
    diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/image_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/image_service.js deleted file mode 100644 index a66e464e702..00000000000 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/image_service.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const getUrl = file => URL.createObjectURL(file); diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 4f66d6bf354..10796f319bf 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -94,7 +94,8 @@ margin-bottom: 16px; } - .boards-list { + .boards-list, + .board-swimlanes { height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32}); } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index c1f5b3a3c7b..049660220df 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -45,7 +45,8 @@ } } -.boards-list { +.boards-list, +.board-swimlanes { height: calc(100vh - #{$issue-board-list-difference-xs}); overflow-x: scroll; min-height: 200px; @@ -576,29 +577,8 @@ } } -.board-epics-swimlanes { +.board-swimlanes { overflow-x: auto; - min-height: calc(100vh - #{$issue-board-list-difference-xs}); - - @include media-breakpoint-only(sm) { - min-height: calc(100vh - #{$issue-board-list-difference-sm}); - } - - @include media-breakpoint-up(md) { - min-height: calc(100vh - #{$issue-board-list-difference-md}); - } - - .with-performance-bar & { - min-height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}); - - @include media-breakpoint-only(sm) { - min-height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}); - } - - @include media-breakpoint-up(md) { - min-height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}); - } - } } .board-header-collapsed-info-icon:hover { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 94af1df2ccb..8daa622dc7c 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -108,12 +108,3 @@ .gl-transition-property-stroke { transition-property: stroke; } - -// temporary class till giltab-ui one is merged -.gl-border-t-2 { - border-top-width: $gl-border-size-2; -} - -.gl-border-b-2 { - border-bottom-width: $gl-border-size-2; -} diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb index 6c6513e0ee4..bcfbc63c31f 100644 --- a/app/graphql/resolvers/milestone_resolver.rb +++ b/app/graphql/resolvers/milestone_resolver.rb @@ -52,7 +52,7 @@ module Resolvers end def group_parameters(args) - return { group_ids: parent.id } unless include_descendants?(args) + return { group_ids: parent.id } unless args[:include_descendants].present? { group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id), @@ -60,10 +60,6 @@ module Resolvers } end - def include_descendants?(args) - args[:include_descendants].present? && Feature.enabled?(:group_milestone_descendants, parent) - end - def group_projects GroupProjectsFinder.new( group: parent, diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 1204f882707..005070cca5c 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true module ClustersHelper - # EE overrides this def has_multiple_clusters? - false + true end def create_new_cluster_label(provider: nil) @@ -95,5 +94,3 @@ module ClustersHelper can?(user, :admin_cluster, cluster) end end - -ClustersHelper.prepend_if_ee('EE::ClustersHelper') diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index d4f5d499b65..0c36bf5cf48 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -2,6 +2,7 @@ module Clusters class Cluster < ApplicationRecord + prepend HasEnvironmentScope include Presentable include Gitlab::Utils::StrongMemoize include FromUnion @@ -81,6 +82,7 @@ module Clusters validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? validate :unique_management_project_environment_scope + validate :unique_environment_scope after_save :clear_reactive_cache! @@ -354,6 +356,12 @@ module Clusters end end + def unique_environment_scope + if clusterable.present? && clusterable.clusters.where(environment_scope: environment_scope).where.not(id: id).exists? + errors.add(:environment_scope, 'cannot add duplicated environment scope') + end + end + def managed_namespace(environment) Clusters::KubernetesNamespaceFinder.new( self, diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 3b893a56bd6..02f7711e927 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true module DeploymentPlatform - # EE would override this and utilize environment argument # rubocop:disable Gitlab/ModuleWithInstanceVariables def deployment_platform(environment: nil) @deployment_platform ||= {} @@ -20,16 +19,27 @@ module DeploymentPlatform find_instance_cluster_platform_kubernetes(environment: environment) end - # EE would override this and utilize environment argument - def find_platform_kubernetes_with_cte(_environment) - Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors + def find_platform_kubernetes_with_cte(environment) + if environment + ::Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?) + .base_and_ancestors + .enabled + .on_environment(environment, relevant_only: true) + .first&.platform_kubernetes + else + Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors .enabled.default_environment .first&.platform_kubernetes + end end - # EE would override this and utilize environment argument def find_instance_cluster_platform_kubernetes(environment: nil) - Clusters::Instance.new.clusters.enabled.default_environment + if environment + ::Clusters::Instance.new.clusters.enabled.on_environment(environment, relevant_only: true) .first&.platform_kubernetes + else + Clusters::Instance.new.clusters.enabled.default_environment + .first&.platform_kubernetes + end end end diff --git a/app/models/packages.rb b/app/models/packages.rb new file mode 100644 index 00000000000..e14c9290093 --- /dev/null +++ b/app/models/packages.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Packages + def self.table_name_prefix + 'packages_' + end +end diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb new file mode 100644 index 00000000000..df8cf68490e --- /dev/null +++ b/app/models/packages/build_info.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Packages::BuildInfo < ApplicationRecord + belongs_to :package, inverse_of: :build_info + belongs_to :pipeline, class_name: 'Ci::Pipeline' +end diff --git a/app/models/packages/composer/metadatum.rb b/app/models/packages/composer/metadatum.rb new file mode 100644 index 00000000000..3026f5ea878 --- /dev/null +++ b/app/models/packages/composer/metadatum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Packages + module Composer + class Metadatum < ApplicationRecord + self.table_name = 'packages_composer_metadata' + self.primary_key = :package_id + + belongs_to :package, -> { where(package_type: :composer) }, inverse_of: :composer_metadatum + + validates :package, :target_sha, :composer_json, presence: true + end + end +end diff --git a/app/models/packages/conan.rb b/app/models/packages/conan.rb new file mode 100644 index 00000000000..01007c3fa78 --- /dev/null +++ b/app/models/packages/conan.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Conan + def self.table_name_prefix + 'packages_conan_' + end + end +end diff --git a/app/models/packages/conan/file_metadatum.rb b/app/models/packages/conan/file_metadatum.rb new file mode 100644 index 00000000000..e1ef62b3959 --- /dev/null +++ b/app/models/packages/conan/file_metadatum.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Packages::Conan::FileMetadatum < ApplicationRecord + belongs_to :package_file, inverse_of: :conan_file_metadatum + + validates :package_file, presence: true + + validates :recipe_revision, + presence: true, + format: { with: Gitlab::Regex.conan_revision_regex } + + validates :package_revision, absence: true, if: :recipe_file? + validates :package_revision, format: { with: Gitlab::Regex.conan_revision_regex }, if: :package_file? + + validates :conan_package_reference, absence: true, if: :recipe_file? + validates :conan_package_reference, format: { with: Gitlab::Regex.conan_package_reference_regex }, if: :package_file? + validate :conan_package_type + + enum conan_file_type: { recipe_file: 1, package_file: 2 } + + RECIPE_FILES = ::Gitlab::Regex::Packages::CONAN_RECIPE_FILES + PACKAGE_FILES = ::Gitlab::Regex::Packages::CONAN_PACKAGE_FILES + PACKAGE_BINARY = 'conan_package.tgz' + + private + + def conan_package_type + unless package_file&.package&.conan? + errors.add(:base, _('Package type must be Conan')) + end + end +end diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb new file mode 100644 index 00000000000..7ec2641177a --- /dev/null +++ b/app/models/packages/conan/metadatum.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Packages::Conan::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :conan) }, inverse_of: :conan_metadatum + + validates :package, presence: true + + validates :package_username, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_component_regex } + + validates :package_channel, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_component_regex } + + validate :conan_package_type + + def recipe + "#{package.name}/#{package.version}@#{package_username}/#{package_channel}" + end + + def recipe_path + recipe.tr('@', '/') + end + + def self.package_username_from(full_path:) + full_path.tr('/', '+') + end + + def self.full_path_from(package_username:) + package_username.tr('+', '/') + end + + private + + def conan_package_type + unless package&.conan? + errors.add(:base, _('Package type must be Conan')) + end + end +end diff --git a/app/models/packages/dependency.rb b/app/models/packages/dependency.rb new file mode 100644 index 00000000000..51b80934827 --- /dev/null +++ b/app/models/packages/dependency.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +class Packages::Dependency < ApplicationRecord + has_many :dependency_links, class_name: 'Packages::DependencyLink' + + validates :name, :version_pattern, presence: true + + validates :name, uniqueness: { scope: :version_pattern } + + NAME_VERSION_PATTERN_TUPLE_MATCHING = '(name, version_pattern) = (?, ?)'.freeze + MAX_STRING_LENGTH = 255.freeze + MAX_CHUNKED_QUERIES_COUNT = 10.freeze + + def self.ids_for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200) + names_and_version_patterns.reject! { |key, value| key.size > MAX_STRING_LENGTH || value.size > MAX_STRING_LENGTH } + raise ArgumentError, 'Too many names_and_version_patterns' if names_and_version_patterns.size > MAX_CHUNKED_QUERIES_COUNT * chunk_size + + matched_ids = [] + names_and_version_patterns.each_slice(chunk_size) do |tuples| + where_statement = Array.new(tuples.size, NAME_VERSION_PATTERN_TUPLE_MATCHING) + .join(' OR ') + ids = where(where_statement, *tuples.flatten) + .limit(max_rows_limit + 1) + .pluck(:id) + matched_ids.concat(ids) + + raise ArgumentError, 'Too many Dependencies selected' if matched_ids.size > max_rows_limit + end + + matched_ids + end + + def self.for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200) + ids = ids_for_package_names_and_version_patterns(names_and_version_patterns, chunk_size, max_rows_limit) + + return none if ids.empty? + + id_in(ids) + end + + def self.pluck_ids_and_names + pluck(:id, :name) + end + + def orphaned? + self.dependency_links.empty? + end +end diff --git a/app/models/packages/dependency_link.rb b/app/models/packages/dependency_link.rb new file mode 100644 index 00000000000..51018602bdc --- /dev/null +++ b/app/models/packages/dependency_link.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +class Packages::DependencyLink < ApplicationRecord + belongs_to :package, inverse_of: :dependency_links + belongs_to :dependency, inverse_of: :dependency_links, class_name: 'Packages::Dependency' + has_one :nuget_metadatum, inverse_of: :dependency_link, class_name: 'Packages::Nuget::DependencyLinkMetadatum' + + validates :package, :dependency, presence: true + + validates :dependency_type, + uniqueness: { scope: %i[package_id dependency_id] } + + enum dependency_type: { dependencies: 1, devDependencies: 2, bundleDependencies: 3, peerDependencies: 4 } + + scope :with_dependency_type, ->(dependency_type) { where(dependency_type: dependency_type) } + scope :includes_dependency, -> { includes(:dependency) } + scope :for_package, ->(package) { where(package_id: package.id) } + scope :preload_dependency, -> { preload(:dependency) } + scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) } +end diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb new file mode 100644 index 00000000000..b38b691ed6c --- /dev/null +++ b/app/models/packages/go/module.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Packages + module Go + class Module + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :name, :path + + def initialize(project, name, path) + @project = project + @name = name + @path = path + end + + def versions + strong_memoize(:versions) { Packages::Go::VersionFinder.new(self).execute } + end + + def version_by(ref: nil, commit: nil) + raise ArgumentError.new 'no filter specified' unless ref || commit + raise ArgumentError.new 'ref and commit are mutually exclusive' if ref && commit + + if commit + return version_by_sha(commit) if commit.is_a? String + + return version_by_commit(commit) + end + + return version_by_name(ref) if ref.is_a? String + + version_by_ref(ref) + end + + def path_valid?(major) + m = /\/v(\d+)$/i.match(@name) + + case major + when 0, 1 + m.nil? + else + !m.nil? && m[1].to_i == major + end + end + + def gomod_valid?(gomod) + if Feature.enabled?(:go_proxy_disable_gomod_validation, @project) + return gomod&.start_with?("module ") + end + + gomod&.split("\n", 2)&.first == "module #{@name}" + end + + private + + def version_by_name(name) + # avoid a Gitaly call if possible + if strong_memoized?(:versions) + v = versions.find { |v| v.name == ref } + return v if v + end + + ref = @project.repository.find_tag(name) || @project.repository.find_branch(name) + return unless ref + + version_by_ref(ref) + end + + def version_by_ref(ref) + # reuse existing versions + if strong_memoized?(:versions) + v = versions.find { |v| v.ref == ref } + return v if v + end + + commit = ref.dereferenced_target + semver = Packages::SemVer.parse(ref.name, prefixed: true) + Packages::Go::ModuleVersion.new(self, :ref, commit, ref: ref, semver: semver) + end + + def version_by_sha(sha) + commit = @project.commit_by(oid: sha) + return unless ref + + version_by_commit(commit) + end + + def version_by_commit(commit) + Packages::Go::ModuleVersion.new(self, :commit, commit) + end + end + end +end diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb new file mode 100644 index 00000000000..a50c78f8e69 --- /dev/null +++ b/app/models/packages/go/module_version.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Packages + module Go + class ModuleVersion + include Gitlab::Utils::StrongMemoize + + VALID_TYPES = %i[ref commit pseudo].freeze + + attr_reader :mod, :type, :ref, :commit + + delegate :major, to: :@semver, allow_nil: true + delegate :minor, to: :@semver, allow_nil: true + delegate :patch, to: :@semver, allow_nil: true + delegate :prerelease, to: :@semver, allow_nil: true + delegate :build, to: :@semver, allow_nil: true + + def initialize(mod, type, commit, name: nil, semver: nil, ref: nil) + raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type + raise ArgumentError.new("mod is required") unless mod + raise ArgumentError.new("commit is required") unless commit + + if type == :ref + raise ArgumentError.new("ref is required") unless ref + elsif type == :pseudo + raise ArgumentError.new("name is required") unless name + raise ArgumentError.new("semver is required") unless semver + end + + @mod = mod + @type = type + @commit = commit + @name = name if name + @semver = semver if semver + @ref = ref if ref + end + + def name + @name || @ref&.name + end + + def full_name + "#{mod.name}@#{name || commit.sha}" + end + + def gomod + strong_memoize(:gomod) do + if strong_memoized?(:blobs) + blob_at(@mod.path + '/go.mod') + elsif @mod.path.empty? + @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data + else + @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data + end + end + end + + def archive + suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1 + + Zip::OutputStream.write_buffer do |zip| + files.each do |file| + zip.put_next_entry "#{full_name}/#{file[suffix_len...]}" + zip.write blob_at(file) + end + end + end + + def files + strong_memoize(:files) do + ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } } + end + end + + def excluded + strong_memoize(:excluded) do + ls_tree + .filter { |f| f.end_with?('/go.mod') && f != @mod.path + '/go.mod' } + .map { |f| f[0..-7] } + end + end + + def valid? + @mod.path_valid?(major) && @mod.gomod_valid?(gomod) + end + + private + + def blob_at(path) + return if path.nil? || path.empty? + + path = path[1..] if path.start_with? '/' + + blobs.find { |x| x.path == path }&.data + end + + def blobs + strong_memoize(:blobs) { @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) } + end + + def ls_tree + strong_memoize(:ls_tree) do + path = + if @mod.path.empty? + '.' + else + @mod.path + end + + @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path) + end + end + end + end +end diff --git a/app/models/packages/maven.rb b/app/models/packages/maven.rb new file mode 100644 index 00000000000..5c1581ce0b7 --- /dev/null +++ b/app/models/packages/maven.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Maven + def self.table_name_prefix + 'packages_maven_' + end + end +end diff --git a/app/models/packages/maven/metadatum.rb b/app/models/packages/maven/metadatum.rb new file mode 100644 index 00000000000..b7f27fb9e06 --- /dev/null +++ b/app/models/packages/maven/metadatum.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Packages::Maven::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :maven) } + + validates :package, presence: true + + validates :path, + presence: true, + format: { with: Gitlab::Regex.maven_path_regex } + + validates :app_group, + presence: true, + format: { with: Gitlab::Regex.maven_app_group_regex } + + validates :app_name, + presence: true, + format: { with: Gitlab::Regex.maven_app_name_regex } + + validate :maven_package_type + + private + + def maven_package_type + unless package&.maven? + errors.add(:base, _('Package type must be Maven')) + end + end +end diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb new file mode 100644 index 00000000000..42c167e9b7f --- /dev/null +++ b/app/models/packages/nuget.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Nuget + def self.table_name_prefix + 'packages_nuget_' + end + end +end diff --git a/app/models/packages/nuget/dependency_link_metadatum.rb b/app/models/packages/nuget/dependency_link_metadatum.rb new file mode 100644 index 00000000000..b586b55d3f0 --- /dev/null +++ b/app/models/packages/nuget/dependency_link_metadatum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Packages::Nuget::DependencyLinkMetadatum < ApplicationRecord + self.primary_key = :dependency_link_id + + belongs_to :dependency_link, inverse_of: :nuget_metadatum + + validates :dependency_link, :target_framework, presence: true + + validate :ensure_nuget_package_type + + private + + def ensure_nuget_package_type + return if dependency_link&.package&.nuget? + + errors.add(:base, _('Package type must be NuGet')) + end +end diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb new file mode 100644 index 00000000000..1db8c0eddbf --- /dev/null +++ b/app/models/packages/nuget/metadatum.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Packages::Nuget::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_metadatum + + validates :package, presence: true + validates :license_url, public_url: { allow_blank: true } + validates :project_url, public_url: { allow_blank: true } + validates :icon_url, public_url: { allow_blank: true } + + validate :ensure_at_least_one_field_supplied + validate :ensure_nuget_package_type + + private + + def ensure_at_least_one_field_supplied + return if license_url? || project_url? || icon_url? + + errors.add(:base, _('Nuget metadatum must have at least license_url, project_url or icon_url set')) + end + + def ensure_nuget_package_type + return if package&.nuget? + + errors.add(:base, _('Package type must be NuGet')) + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb new file mode 100644 index 00000000000..d6633456de4 --- /dev/null +++ b/app/models/packages/package.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true +class Packages::Package < ApplicationRecord + include Sortable + include Gitlab::SQL::Pattern + include UsageStatistics + + belongs_to :project + # package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics + has_many :package_files, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :dependency_links, inverse_of: :package, class_name: 'Packages::DependencyLink' + has_many :tags, inverse_of: :package, class_name: 'Packages::Tag' + has_one :conan_metadatum, inverse_of: :package, class_name: 'Packages::Conan::Metadatum' + has_one :pypi_metadatum, inverse_of: :package, class_name: 'Packages::Pypi::Metadatum' + has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum' + has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum' + has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum' + has_one :build_info, inverse_of: :package + + accepts_nested_attributes_for :conan_metadatum + accepts_nested_attributes_for :maven_metadatum + + delegate :recipe, :recipe_path, to: :conan_metadatum, prefix: :conan + + validates :project, presence: true + validates :name, presence: true + + validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan? + + validates :name, + uniqueness: { scope: %i[project_id version package_type] }, unless: :conan? + + validate :valid_conan_package_recipe, if: :conan? + validate :valid_npm_package_name, if: :npm? + validate :valid_composer_global_name, if: :composer? + validate :package_already_taken, if: :npm? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? } + validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? + validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? + validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } + + enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6 } + + scope :with_name, ->(name) { where(name: name) } + scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } + scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } + scope :with_version, ->(version) { where(version: version) } + scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } + scope :with_package_type, ->(package_type) { where(package_type: package_type) } + + scope :with_conan_channel, ->(package_channel) do + joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel }) + end + scope :with_conan_username, ->(package_username) do + joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username }) + end + + scope :with_composer_target, -> (target) do + includes(:composer_metadatum) + .joins(:composer_metadatum) + .where(Packages::Composer::Metadatum.table_name => { target_sha: target }) + end + scope :preload_composer, -> { preload(:composer_metadatum) } + + scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + + scope :has_version, -> { where.not(version: nil) } + scope :processed, -> do + where.not(package_type: :nuget).or( + where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) + ) + end + scope :preload_files, -> { preload(:package_files) } + scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } + scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } + scope :select_distinct_name, -> { select(:name).distinct } + + # Sorting + scope :order_created, -> { reorder('created_at ASC') } + scope :order_created_desc, -> { reorder('created_at DESC') } + scope :order_name, -> { reorder('name ASC') } + scope :order_name_desc, -> { reorder('name DESC') } + scope :order_version, -> { reorder('version ASC') } + scope :order_version_desc, -> { reorder('version DESC') } + scope :order_type, -> { reorder('package_type ASC') } + scope :order_type_desc, -> { reorder('package_type DESC') } + scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } + scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } + scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') } + scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') } + + def self.for_projects(projects) + return none unless projects.any? + + where(project_id: projects) + end + + def self.only_maven_packages_with_path(path) + joins(:maven_metadatum).where(packages_maven_metadata: { path: path }) + end + + def self.by_name_and_file_name(name, file_name) + with_name(name) + .joins(:package_files) + .where(packages_package_files: { file_name: file_name }).last! + end + + def self.by_file_name_and_sha256(file_name, sha256) + joins(:package_files) + .where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last! + end + + def self.pluck_names + pluck(:name) + end + + def self.pluck_versions + pluck(:version) + end + + def self.sort_by_attribute(method) + case method.to_s + when 'created_asc' then order_created + when 'created_at_asc' then order_created + when 'name_asc' then order_name + when 'name_desc' then order_name_desc + when 'version_asc' then order_version + when 'version_desc' then order_version_desc + when 'type_asc' then order_type + when 'type_desc' then order_type_desc + when 'project_name_asc' then order_project_name + when 'project_name_desc' then order_project_name_desc + when 'project_path_asc' then order_project_path + when 'project_path_desc' then order_project_path_desc + else + order_created_desc + end + end + + def versions + project.packages + .with_name(name) + .where.not(version: version) + .with_package_type(package_type) + .order(:version) + end + + def pipeline + build_info&.pipeline + end + + def tag_names + tags.pluck(:name) + end + + private + + def valid_conan_package_recipe + recipe_exists = project.packages + .conan + .includes(:conan_metadatum) + .with_name(name) + .with_version(version) + .with_conan_channel(conan_metadatum.package_channel) + .with_conan_username(conan_metadatum.package_username) + .id_not_in(id) + .exists? + + errors.add(:base, _('Package recipe already exists')) if recipe_exists + end + + def valid_composer_global_name + # .default_scoped is required here due to a bug in rails that leaks + # the scope and adds `self` to the query incorrectly + # See https://github.com/rails/rails/pull/35186 + if Packages::Package.default_scoped.composer.with_name(name).where.not(project_id: project_id).exists? + errors.add(:name, 'is already taken by another project') + end + end + + def valid_npm_package_name + return unless project&.root_namespace + + unless name =~ %r{\A@#{project.root_namespace.path}/[^/]+\z} + errors.add(:name, 'is not valid') + end + end + + def package_already_taken + return unless project + + if project.package_already_taken?(name) + errors.add(:base, _('Package already exists')) + end + end +end diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb new file mode 100644 index 00000000000..9b412cd6d6a --- /dev/null +++ b/app/models/packages/package_file.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +class Packages::PackageFile < ApplicationRecord + include UpdateProjectStatistics + + delegate :project, :project_id, to: :package + delegate :conan_file_type, to: :conan_file_metadatum + + belongs_to :package + + has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum' + + accepts_nested_attributes_for :conan_file_metadatum + + validates :package, presence: true + validates :file, presence: true + validates :file_name, presence: true + + scope :recent, -> { order(id: :desc) } + scope :with_file_name, ->(file_name) { where(file_name: file_name) } + scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) } + scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) } + scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } + + scope :with_conan_file_type, ->(file_type) do + joins(:conan_file_metadatum) + .where(packages_conan_file_metadata: { conan_file_type: ::Packages::Conan::FileMetadatum.conan_file_types[file_type] }) + end + + scope :with_conan_package_reference, ->(conan_package_reference) do + joins(:conan_file_metadatum) + .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference }) + end + + mount_uploader :file, Packages::PackageFileUploader + + after_save :update_file_metadata, if: :saved_change_to_file? + + update_project_statistics project_statistics_name: :packages_size + + def update_file_metadata + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) + self.update_column(:size, file.size) unless file.size == self.size + end + + def download_path + Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) + end + + def local? + file_store == ::Packages::PackageFileUploader::Store::LOCAL + end +end + +Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFileGeo') diff --git a/app/models/packages/pypi.rb b/app/models/packages/pypi.rb new file mode 100644 index 00000000000..fc8a55caa31 --- /dev/null +++ b/app/models/packages/pypi.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Pypi + def self.table_name_prefix + 'packages_pypi_' + end + end +end diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb new file mode 100644 index 00000000000..7e6456ad964 --- /dev/null +++ b/app/models/packages/pypi/metadatum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Packages::Pypi::Metadatum < ApplicationRecord + self.primary_key = :package_id + + belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum + + validates :package, presence: true + + validate :pypi_package_type + + private + + def pypi_package_type + unless package&.pypi? + errors.add(:base, _('Package type must be PyPi')) + end + end +end diff --git a/app/models/packages/sem_ver.rb b/app/models/packages/sem_ver.rb new file mode 100644 index 00000000000..b73d51b08b7 --- /dev/null +++ b/app/models/packages/sem_ver.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Packages::SemVer + attr_accessor :major, :minor, :patch, :prerelease, :build + + def initialize(major = 0, minor = 0, patch = 0, prerelease = nil, build = nil, prefixed: false) + @major = major + @minor = minor + @patch = patch + @prerelease = prerelease + @build = build + @prefixed = prefixed + end + + def prefixed? + @prefixed + end + + def ==(other) + self.class == other.class && + self.major == other.major && + self.minor == other.minor && + self.patch == other.patch && + self.prerelease == other.prerelease && + self.build == other.build + end + + def to_s + s = "#{prefixed? ? 'v' : ''}#{major || 0}.#{minor || 0}.#{patch || 0}" + s += "-#{prerelease}" if prerelease + s += "+#{build}" if build + + s + end + + def self.match(str, prefixed: false) + return unless str&.start_with?('v') == prefixed + + str = str[1..] if prefixed + + Gitlab::Regex.semver_regex.match(str) + end + + def self.match?(str, prefixed: false) + !match(str, prefixed: prefixed).nil? + end + + def self.parse(str, prefixed: false) + m = match str, prefixed: prefixed + return unless m + + new(m[1].to_i, m[2].to_i, m[3].to_i, m[4], m[5], prefixed: prefixed) + end +end diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb new file mode 100644 index 00000000000..771d016daed --- /dev/null +++ b/app/models/packages/tag.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class Packages::Tag < ApplicationRecord + belongs_to :package, inverse_of: :tags + + validates :package, :name, presence: true + + FOR_PACKAGES_TAGS_LIMIT = 200.freeze + NUGET_TAGS_SEPARATOR = ' ' # https://docs.microsoft.com/en-us/nuget/reference/nuspec#tags + + scope :preload_package, -> { preload(:package) } + scope :with_name, -> (name) { where(name: name) } + + def self.for_packages(packages) + where(package_id: packages.select(:id)) + .order(updated_at: :desc) + .limit(FOR_PACKAGES_TAGS_LIMIT) + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 9b1e453216b..d51b5bc7b34 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -190,6 +190,10 @@ class Project < ApplicationRecord has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project has_many :fork_network_projects, through: :fork_network, source: :projects + # Packages + has_many :packages, class_name: 'Packages::Package' + has_many :package_files, through: :packages, class_name: 'Packages::PackageFile' + has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :export_jobs, class_name: 'ProjectExportJob' @@ -1700,10 +1704,10 @@ class Project < ApplicationRecord def pages_url url = pages_group_url - url_path = full_path.partition('/').last.downcase + url_path = full_path.partition('/').last # If the project path is the same as host, we serve it as group page - return url if url == "#{Settings.pages.protocol}://#{url_path}" + return url if url == "#{Settings.pages.protocol}://#{url_path}".downcase "#{url}/#{url_path}" end @@ -2421,6 +2425,22 @@ class Project < ApplicationRecord end alias_method :service_desk_enabled?, :service_desk_enabled + def root_namespace + if namespace.has_parent? + namespace.root_ancestor + else + namespace + end + end + + def package_already_taken?(package_name) + namespace.root_ancestor.all_projects + .joins(:packages) + .where.not(id: id) + .merge(Packages::Package.with_name(package_name)) + .exists? + end + private def find_service(services, name) diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 571b586056b..bfd23d2a334 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -11,6 +11,7 @@ class PrometheusMetric < ApplicationRecord validates :group, presence: true validates :y_label, presence: true validates :unit, presence: true + validates :identifier, uniqueness: { scope: :project_id }, allow_nil: true validates :project, presence: true, unless: :common? validates :project, absence: true, if: :common? diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 4eb0e244e54..6d21ae8a4f8 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -13,8 +13,7 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated end def can_add_cluster? - can?(current_user, :add_cluster, clusterable) && - (has_no_clusters? || multiple_clusters_available?) + can?(current_user, :add_cluster, clusterable) end def can_create_cluster? @@ -81,17 +80,6 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated def learn_more_link raise NotImplementedError end - - private - - # Overridden on EE module - def multiple_clusters_available? - false - end - - def has_no_clusters? - clusterable.clusters.empty? - end end ClusterablePresenter.prepend_if_ee('EE::ClusterablePresenter') diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 7b5bf6b32c2..6693a58683f 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -19,10 +19,6 @@ module Clusters cluster = Clusters::Cluster.new(cluster_params) - unless can_create_cluster? - cluster.errors.add(:base, _('Instance does not support multiple Kubernetes clusters')) - end - validate_management_project_permissions(cluster) return cluster if cluster.errors.present? @@ -55,16 +51,9 @@ module Clusters end end - # EE would override this method - def can_create_cluster? - clusterable.clusters.empty? - end - def validate_management_project_permissions(cluster) Clusters::Management::ValidateManagementProjectPermissionsService.new(current_user) .execute(cluster, params[:management_project_id]) end end end - -Clusters::CreateService.prepend_if_ee('EE::Clusters::CreateService') diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb index 739f2b9531a..a6bece391f2 100644 --- a/app/services/metrics/dashboard/clone_dashboard_service.rb +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -10,29 +10,29 @@ module Metrics ALLOWED_FILE_TYPE = '.yml' USER_DASHBOARDS_DIR = ::Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT + SEQUENCES = { + ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::Sorter + ].freeze, + + ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter + ].freeze, + + ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::Sorter + ].freeze + }.freeze steps :check_push_authorized, - :check_branch_name, - :check_file_type, - :check_dashboard_template, - :create_file, - :refresh_repository_method_caches - - class << self - def sequences - @sequences ||= { - ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [ - ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::Sorter - ].freeze, - - ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [ - ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter - ].freeze - }.freeze - end - end + :check_branch_name, + :check_file_type, + :check_dashboard_template, + :create_file, + :refresh_repository_method_caches def execute execute_steps @@ -173,10 +173,8 @@ module Metrics end def sequence - self.class.sequences[dashboard_template] || [] + SEQUENCES[dashboard_template] || [] end end end end - -Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService') diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb new file mode 100644 index 00000000000..00ac1d6fb23 --- /dev/null +++ b/app/services/metrics/dashboard/cluster_dashboard_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Fetches the system metrics dashboard and formats the output. +# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. +module Metrics + module Dashboard + class ClusterDashboardService < ::Metrics::Dashboard::PredefinedDashboardService + DASHBOARD_PATH = 'config/prometheus/cluster_metrics.yml' + DASHBOARD_NAME = 'Cluster' + + SEQUENCE = [ + STAGES::ClusterEndpointInserter, + STAGES::PanelIdsInserter, + STAGES::Sorter + ].freeze + + class << self + def valid_params?(params) + # support selecting this service by cluster id via .find + # Use super to support selecting this service by dashboard_path via .find_raw + (params[:cluster].present? && params[:embedded] != 'true') || super + end + end + + # Permissions are handled at the controller level + def allowed? + true + end + end + end +end diff --git a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb new file mode 100644 index 00000000000..6fb39ed3004 --- /dev/null +++ b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# +module Metrics + module Dashboard + class ClusterMetricsEmbedService < Metrics::Dashboard::DynamicEmbedService + class << self + def valid_params?(params) + [ + params[:cluster], + embedded?(params[:embedded]), + params[:group].present?, + params[:title].present?, + params[:y_label].present? + ].all? + end + end + + private + + # Permissions are handled at the controller level + def allowed? + true + end + + def dashboard_path + ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH + end + + def sequence + [ + STAGES::ClusterEndpointInserter, + STAGES::PanelIdsInserter + ] + end + end + end +end diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb new file mode 100644 index 00000000000..2db5c4e507b --- /dev/null +++ b/app/services/packages/conan/create_package_file_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Packages + module Conan + class CreatePackageFileService + attr_reader :package, :file, :params + + def initialize(package, file, params) + @package = package + @file = file + @params = params + end + + def execute + package.package_files.create!( + file: file, + size: params['file.size'], + file_name: params[:file_name], + file_sha1: params['file.sha1'], + file_md5: params['file.md5'], + conan_file_metadatum_attributes: { + recipe_revision: params[:recipe_revision], + package_revision: params[:package_revision], + conan_package_reference: params[:conan_package_reference], + conan_file_type: params[:conan_file_type] + } + ) + end + end + end +end diff --git a/app/services/packages/conan/create_package_service.rb b/app/services/packages/conan/create_package_service.rb new file mode 100644 index 00000000000..22a0436c5fb --- /dev/null +++ b/app/services/packages/conan/create_package_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Packages + module Conan + class CreatePackageService < BaseService + def execute + project.packages.create!( + name: params[:package_name], + version: params[:package_version], + package_type: :conan, + conan_metadatum_attributes: { + package_username: params[:package_username], + package_channel: params[:package_channel] + } + ) + end + end + end +end diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb new file mode 100644 index 00000000000..4513616bad2 --- /dev/null +++ b/app/services/packages/conan/search_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Packages + module Conan + class SearchService < BaseService + include ActiveRecord::Sanitization::ClassMethods + + WILDCARD = '*' + RECIPE_SEPARATOR = '@' + + def initialize(user, params) + super(nil, user, params) + end + + def execute + ServiceResponse.success(payload: { results: search_results }) + end + + private + + def search_results + return [] if wildcard_query? + + return search_for_single_package(sanitized_query) if params[:query].include?(RECIPE_SEPARATOR) + + search_packages(build_query) + end + + def wildcard_query? + params[:query] == WILDCARD + end + + def build_query + return "#{sanitized_query}%" if params[:query].end_with?(WILDCARD) + + sanitized_query + end + + def search_packages(query) + ::Packages::Conan::PackageFinder.new(current_user, query: query).execute.map(&:conan_recipe) + end + + def search_for_single_package(query) + name, version, username, _ = query.split(/[@\/]/) + full_path = Packages::Conan::Metadatum.full_path_from(package_username: username) + project = Project.find_by_full_path(full_path) + return unless current_user.can?(:read_package, project) + + result = project.packages.with_name(name).with_version(version).order_created.last + [result&.conan_recipe].compact + end + + def sanitized_query + @sanitized_query ||= sanitize_sql_like(params[:query].delete(WILDCARD)) + end + end + end +end diff --git a/app/services/packages/create_dependency_service.rb b/app/services/packages/create_dependency_service.rb new file mode 100644 index 00000000000..2999885d55d --- /dev/null +++ b/app/services/packages/create_dependency_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +module Packages + class CreateDependencyService < BaseService + attr_reader :package, :dependencies + + def initialize(package, dependencies) + @package = package + @dependencies = dependencies + end + + def execute + Packages::DependencyLink.dependency_types.each_key do |type| + create_dependency(type) + end + end + + private + + def create_dependency(type) + return unless dependencies[type].is_a?(Hash) + + names_and_version_patterns = dependencies[type] + existing_ids, existing_names = find_existing_ids_and_names(names_and_version_patterns) + dependencies_to_insert = names_and_version_patterns + + if existing_names.any? + dependencies_to_insert = names_and_version_patterns.reject { |k, _| k.in?(existing_names) } + end + + ActiveRecord::Base.transaction do + inserted_ids = bulk_insert_package_dependencies(dependencies_to_insert) + bulk_insert_package_dependency_links(type, (existing_ids + inserted_ids)) + end + end + + def find_existing_ids_and_names(names_and_version_patterns) + ids_and_names = Packages::Dependency.for_package_names_and_version_patterns(names_and_version_patterns) + .pluck_ids_and_names + ids = ids_and_names.map(&:first) || [] + names = ids_and_names.map(&:second) || [] + [ids, names] + end + + def bulk_insert_package_dependencies(names_and_version_patterns) + return [] if names_and_version_patterns.empty? + + rows = names_and_version_patterns.map do |name, version_pattern| + { + name: name, + version_pattern: version_pattern + } + end + + ids = database.bulk_insert(Packages::Dependency.table_name, rows, return_ids: true, on_conflict: :do_nothing) + return ids if ids.size == names_and_version_patterns.size + + Packages::Dependency.uncached do + # The bulk_insert statement above do not dirty the query cache. To make + # sure that the results are fresh from the database and not from a stalled + # and potentially wrong cache, this query has to be done with the query + # chache disabled. + Packages::Dependency.ids_for_package_names_and_version_patterns(names_and_version_patterns) + end + end + + def bulk_insert_package_dependency_links(type, dependency_ids) + rows = dependency_ids.map do |dependency_id| + { + package_id: package.id, + dependency_id: dependency_id, + dependency_type: Packages::DependencyLink.dependency_types[type.to_s] + } + end + + database.bulk_insert(Packages::DependencyLink.table_name, rows) + end + + def database + ::Gitlab::Database + end + end +end diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb new file mode 100644 index 00000000000..0ebceeee779 --- /dev/null +++ b/app/services/packages/create_package_file_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Packages + class CreatePackageFileService + attr_reader :package, :params + + def initialize(package, params) + @package = package + @params = params + end + + def execute + package.package_files.create!( + file: params[:file], + size: params[:size], + file_name: params[:file_name], + file_sha1: params[:file_sha1], + file_sha256: params[:file_sha256], + file_md5: params[:file_md5] + ) + end + end +end diff --git a/app/services/packages/maven/create_package_service.rb b/app/services/packages/maven/create_package_service.rb new file mode 100644 index 00000000000..aca5d28ca98 --- /dev/null +++ b/app/services/packages/maven/create_package_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module Packages + module Maven + class CreatePackageService < BaseService + def execute + app_group, _, app_name = params[:name].rpartition('/') + app_group.tr!('/', '.') + + package = project.packages.create!( + name: params[:name], + version: params[:version], + package_type: :maven, + maven_metadatum_attributes: { + path: params[:path], + app_group: app_group, + app_name: app_name, + app_version: params[:version] + } + ) + + build = params[:build] + package.create_build_info!(pipeline: build.pipeline) if build.present? + + package + end + end + end +end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb new file mode 100644 index 00000000000..50a008843ad --- /dev/null +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +module Packages + module Maven + class FindOrCreatePackageService < BaseService + MAVEN_METADATA_FILE = 'maven-metadata.xml'.freeze + + def execute + package = ::Packages::Maven::PackageFinder + .new(params[:path], current_user, project: project).execute + + unless package + if params[:file_name] == MAVEN_METADATA_FILE + # Maven uploads several files during `mvn deploy` in next order: + # - my-company/my-app/1.0-SNAPSHOT/my-app.jar + # - my-company/my-app/1.0-SNAPSHOT/my-app.pom + # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml + # - my-company/my-app/maven-metadata.xml + # + # The last xml file does not have VERSION in URL because it contains + # information about all versions. + package_name, version = params[:path], nil + else + package_name, _, version = params[:path].rpartition('/') + end + + package_params = { + name: package_name, + path: params[:path], + version: version, + build: params[:build] + } + + package = ::Packages::Maven::CreatePackageService + .new(project, current_user, package_params).execute + end + + package + end + end + end +end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb new file mode 100644 index 00000000000..cf927683ce9 --- /dev/null +++ b/app/services/packages/npm/create_package_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +module Packages + module Npm + class CreatePackageService < BaseService + include Gitlab::Utils::StrongMemoize + + def execute + return error('Version is empty.', 400) if version.blank? + return error('Package already exists.', 403) if current_package_exists? + + ActiveRecord::Base.transaction { create_package! } + end + + private + + def create_package! + package = project.packages.create!( + name: name, + version: version, + package_type: 'npm' + ) + + if build.present? + package.create_build_info!(pipeline: build.pipeline) + end + + ::Packages::CreatePackageFileService.new(package, file_params).execute + ::Packages::CreateDependencyService.new(package, package_dependencies).execute + ::Packages::Npm::CreateTagService.new(package, dist_tag).execute + + package + end + + def current_package_exists? + project.packages + .npm + .with_name(name) + .with_version(version) + .exists? + end + + def name + params[:name] + end + + def version + strong_memoize(:version) do + params[:versions].each_key.first + end + end + + def version_data + params[:versions][version] + end + + def build + params[:build] + end + + def dist_tag + params['dist-tags'].each_key.first + end + + def package_file_name + strong_memoize(:package_file_name) do + "#{name}-#{version}.tgz" + end + end + + def attachment + strong_memoize(:attachment) do + params['_attachments'][package_file_name] + end + end + + def file_params + { + file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])), + size: attachment['length'], + file_sha1: version_data[:dist][:shasum], + file_name: package_file_name + } + end + + def package_dependencies + _version, versions_data = params[:versions].first + versions_data + end + end + end +end diff --git a/app/services/packages/npm/create_tag_service.rb b/app/services/packages/npm/create_tag_service.rb new file mode 100644 index 00000000000..82974d0ca4b --- /dev/null +++ b/app/services/packages/npm/create_tag_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module Packages + module Npm + class CreateTagService + include Gitlab::Utils::StrongMemoize + + attr_reader :package, :tag_name + + def initialize(package, tag_name) + @package = package + @tag_name = tag_name + end + + def execute + if existing_tag.present? + existing_tag.update_column(:package_id, package.id) + existing_tag + else + package.tags.create!(name: tag_name) + end + end + + private + + def existing_tag + strong_memoize(:existing_tag) do + Packages::TagsFinder + .new(package.project, package.name, package_type: package.package_type) + .find_by_name(tag_name) + end + end + end + end +end diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb new file mode 100644 index 00000000000..2be5db732f6 --- /dev/null +++ b/app/services/packages/nuget/create_dependency_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +module Packages + module Nuget + class CreateDependencyService < BaseService + def initialize(package, dependencies = []) + @package = package + @dependencies = dependencies + end + + def execute + return if @dependencies.empty? + + @package.transaction do + create_dependency_links + create_dependency_link_metadata + end + end + + private + + def create_dependency_links + ::Packages::CreateDependencyService + .new(@package, dependencies_for_create_dependency_service) + .execute + end + + def create_dependency_link_metadata + inserted_links = ::Packages::DependencyLink.preload_dependency + .for_package(@package) + + return if inserted_links.empty? + + rows = inserted_links.map do |dependency_link| + raw_dependency = raw_dependency_for(dependency_link.dependency) + + next if raw_dependency[:target_framework].blank? + + { + dependency_link_id: dependency_link.id, + target_framework: raw_dependency[:target_framework] + } + end + + ::Gitlab::Database.bulk_insert(::Packages::Nuget::DependencyLinkMetadatum.table_name, rows.compact) + end + + def raw_dependency_for(dependency) + name = dependency.name + version = dependency.version_pattern.presence + + @dependencies.find do |raw_dependency| + raw_dependency[:name] == name && raw_dependency[:version] == version + end + end + + def dependencies_for_create_dependency_service + names_and_versions = @dependencies.map do |dependency| + [dependency[:name], version_or_empty_string(dependency[:version])] + end.to_h + + { 'dependencies' => names_and_versions } + end + + def version_or_empty_string(version) + return '' if version.blank? + + version + end + end + end +end diff --git a/app/services/packages/nuget/create_package_service.rb b/app/services/packages/nuget/create_package_service.rb new file mode 100644 index 00000000000..68ad7f028e4 --- /dev/null +++ b/app/services/packages/nuget/create_package_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class CreatePackageService < BaseService + TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package' + PACKAGE_VERSION = '0.0.0' + + def execute + project.packages.nuget.create!( + name: TEMPORARY_PACKAGE_NAME, + version: "#{PACKAGE_VERSION}-#{uuid}" + ) + end + + private + + def uuid + SecureRandom.uuid + end + end + end +end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb new file mode 100644 index 00000000000..6fec398fab0 --- /dev/null +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class MetadataExtractionService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + XPATHS = { + package_name: '//xmlns:package/xmlns:metadata/xmlns:id', + package_version: '//xmlns:package/xmlns:metadata/xmlns:version', + license_url: '//xmlns:package/xmlns:metadata/xmlns:licenseUrl', + project_url: '//xmlns:package/xmlns:metadata/xmlns:projectUrl', + icon_url: '//xmlns:package/xmlns:metadata/xmlns:iconUrl' + }.freeze + + XPATH_DEPENDENCIES = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:dependency' + XPATH_DEPENDENCY_GROUPS = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:group' + XPATH_TAGS = '//xmlns:package/xmlns:metadata/xmlns:tags' + + MAX_FILE_SIZE = 4.megabytes.freeze + + def initialize(package_file_id) + @package_file_id = package_file_id + end + + def execute + raise ExtractionError.new('invalid package file') unless valid_package_file? + + extract_metadata(nuspec_file) + end + + private + + def package_file + strong_memoize(:package_file) do + ::Packages::PackageFile.find_by_id(@package_file_id) + end + end + + def valid_package_file? + package_file && + package_file.package&.nuget? && + package_file.file.size.positive? + end + + def extract_metadata(file) + doc = Nokogiri::XML(file) + + XPATHS.transform_values { |query| doc.xpath(query).text.presence } + .compact + .tap do |metadata| + metadata[:package_dependencies] = extract_dependencies(doc) + metadata[:package_tags] = extract_tags(doc) + end + end + + def extract_dependencies(doc) + dependencies = [] + + doc.xpath(XPATH_DEPENDENCIES).each do |node| + dependencies << extract_dependency(node) + end + + doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node| + target_framework = group_node.attr("targetFramework") + + group_node.xpath("xmlns:dependency").each do |node| + dependencies << extract_dependency(node).merge(target_framework: target_framework) + end + end + + dependencies + end + + def extract_dependency(node) + { + name: node.attr('id'), + version: node.attr('version') + }.compact + end + + def extract_tags(doc) + tags = doc.xpath(XPATH_TAGS).text + + return [] if tags.blank? + + tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR) + end + + def nuspec_file + package_file.file.use_file do |file_path| + Zip::File.open(file_path) do |zip_file| + entry = zip_file.glob('*.nuspec').first + + raise ExtractionError.new('nuspec file not found') unless entry + raise ExtractionError.new('nuspec file too big') if entry.size > MAX_FILE_SIZE + + entry.get_input_stream.read + end + end + end + end + end +end diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb new file mode 100644 index 00000000000..f7e09e11819 --- /dev/null +++ b/app/services/packages/nuget/search_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SearchService < BaseService + include Gitlab::Utils::StrongMemoize + include ActiveRecord::ConnectionAdapters::Quoting + + MAX_PER_PAGE = 30 + MAX_VERSIONS_PER_PACKAGE = 10 + PRE_RELEASE_VERSION_MATCHING_TERM = '%-%' + + DEFAULT_OPTIONS = { + include_prerelease_versions: true, + per_page: Kaminari.config.default_per_page, + padding: 0 + }.freeze + + def initialize(project, search_term, options = {}) + @project = project + @search_term = search_term + @options = DEFAULT_OPTIONS.merge(options) + + raise ArgumentError, 'negative per_page' if per_page.negative? + raise ArgumentError, 'negative padding' if padding.negative? + end + + def execute + OpenStruct.new( + total_count: package_names.total_count, + results: search_packages + ) + end + + private + + def search_packages + # custom query to get package names and versions as expected from the nuget search api + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24182#technical-notes + # and https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource + subquery_name = :partition_subquery + arel_table = Arel::Table.new(:partition_subquery) + column_names = Packages::Package.column_names.map do |cn| + "#{subquery_name}.#{quote_column_name(cn)}" + end + + # rubocop: disable CodeReuse/ActiveRecord + pkgs = Packages::Package.select(column_names.join(',')) + .from(package_names_partition, subquery_name) + .where(arel_table[:row_number].lteq(MAX_VERSIONS_PER_PACKAGE)) + + return pkgs if include_prerelease_versions? + + # we can't use pkgs.without_version_like since we have a custom from + pkgs.where.not(arel_table[:version].matches(PRE_RELEASE_VERSION_MATCHING_TERM)) + end + + def package_names_partition + table_name = quote_table_name(Packages::Package.table_name) + name_column = "#{table_name}.#{quote_column_name('name')}" + created_at_column = "#{table_name}.#{quote_column_name('created_at')}" + select_sql = "ROW_NUMBER() OVER (PARTITION BY #{name_column} ORDER BY #{created_at_column} DESC) AS row_number, #{table_name}.*" + + @project.packages + .select(select_sql) + .nuget + .has_version + .without_nuget_temporary_name + .with_name(package_names) + end + + def package_names + strong_memoize(:package_names) do + pkgs = @project.packages + .nuget + .has_version + .without_nuget_temporary_name + .order_name + .select_distinct_name + pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions? + pkgs = pkgs.search_by_name(@search_term) if @search_term.present? + pkgs.page(0) # we're using a padding + .per(per_page) + .padding(padding) + end + end + + def include_prerelease_versions? + @options[:include_prerelease_versions] + end + + def padding + @options[:padding] + end + + def per_page + [@options[:per_page], MAX_PER_PAGE].min + end + end + end +end diff --git a/app/services/packages/nuget/sync_metadatum_service.rb b/app/services/packages/nuget/sync_metadatum_service.rb new file mode 100644 index 00000000000..ca9cc4d5b78 --- /dev/null +++ b/app/services/packages/nuget/sync_metadatum_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SyncMetadatumService + include Gitlab::Utils::StrongMemoize + + def initialize(package, metadata) + @package = package + @metadata = metadata + end + + def execute + if blank_metadata? + metadatum.destroy! if metadatum.persisted? + else + metadatum.update!( + license_url: license_url, + project_url: project_url, + icon_url: icon_url + ) + end + end + + private + + def metadatum + strong_memoize(:metadatum) do + @package.nuget_metadatum || @package.build_nuget_metadatum + end + end + + def blank_metadata? + project_url.blank? && license_url.blank? && icon_url.blank? + end + + def project_url + @metadata[:project_url] + end + + def license_url + @metadata[:license_url] + end + + def icon_url + @metadata[:icon_url] + end + end + end +end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb new file mode 100644 index 00000000000..f72b1386985 --- /dev/null +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class UpdatePackageFromMetadataService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + # used by ExclusiveLeaseGuard + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + InvalidMetadataError = Class.new(StandardError) + + def initialize(package_file) + @package_file = package_file + end + + def execute + raise InvalidMetadataError.new('package name and/or package version not found in metadata') unless valid_metadata? + + try_obtain_lease do + @package_file.transaction do + package = existing_package ? link_to_existing_package : update_linked_package + + update_package(package) + + # Updating file_name updates the path where the file is stored. + # We must pass the file again so that CarrierWave can handle the update + @package_file.update!( + file_name: package_filename, + file: @package_file.file + ) + end + end + end + + private + + def update_package(package) + ::Packages::Nuget::SyncMetadatumService + .new(package, metadata.slice(:project_url, :license_url, :icon_url)) + .execute + ::Packages::UpdateTagsService + .new(package, package_tags) + .execute + rescue => e + raise InvalidMetadataError, e.message + end + + def valid_metadata? + package_name.present? && package_version.present? + end + + def link_to_existing_package + package_to_destroy = @package_file.package + # Updating package_id updates the path where the file is stored. + # We must pass the file again so that CarrierWave can handle the update + @package_file.update!( + package_id: existing_package.id, + file: @package_file.file + ) + package_to_destroy.destroy! + existing_package + end + + def update_linked_package + @package_file.package.update!( + name: package_name, + version: package_version + ) + + ::Packages::Nuget::CreateDependencyService.new(@package_file.package, package_dependencies) + .execute + @package_file.package + end + + def existing_package + strong_memoize(:existing_package) do + @package_file.project.packages + .nuget + .with_name(package_name) + .with_version(package_version) + .first + end + end + + def package_name + metadata[:package_name] + end + + def package_version + metadata[:package_version] + end + + def package_dependencies + metadata.fetch(:package_dependencies, []) + end + + def package_tags + metadata.fetch(:package_tags, []) + end + + def metadata + strong_memoize(:metadata) do + ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute + end + end + + def package_filename + "#{package_name.downcase}.#{package_version.downcase}.nupkg" + end + + # used by ExclusiveLeaseGuard + def lease_key + package_id = existing_package ? existing_package.id : @package_file.package_id + "packages:nuget:update_package_from_metadata_service:package:#{package_id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb new file mode 100644 index 00000000000..1313fc80e33 --- /dev/null +++ b/app/services/packages/pypi/create_package_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Packages + module Pypi + class CreatePackageService < BaseService + include ::Gitlab::Utils::StrongMemoize + + def execute + ::Packages::Package.transaction do + Packages::Pypi::Metadatum.upsert( + package_id: created_package.id, + required_python: params[:requires_python] + ) + + ::Packages::CreatePackageFileService.new(created_package, file_params).execute + end + end + + private + + def created_package + strong_memoize(:created_package) do + project + .packages + .pypi + .safe_find_or_create_by!(name: params[:name], version: params[:version]) + end + end + + def file_params + { + file: params[:content], + file_name: params[:content].original_filename, + file_md5: params[:md5_digest], + file_sha256: params[:sha256_digest] + } + end + end + end +end diff --git a/app/services/packages/remove_tag_service.rb b/app/services/packages/remove_tag_service.rb new file mode 100644 index 00000000000..465b85506a6 --- /dev/null +++ b/app/services/packages/remove_tag_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module Packages + class RemoveTagService < BaseService + attr_reader :package_tag + + def initialize(package_tag) + raise ArgumentError, "Package tag must be set" if package_tag.blank? + + @package_tag = package_tag + end + + def execute + package_tag.delete + end + end +end diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb new file mode 100644 index 00000000000..da50cd3479e --- /dev/null +++ b/app/services/packages/update_tags_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +module Packages + class UpdateTagsService + include Gitlab::Utils::StrongMemoize + + def initialize(package, tags = []) + @package = package + @tags = tags + end + + def execute + return if @tags.empty? + + tags_to_destroy = existing_tags - @tags + tags_to_create = @tags - existing_tags + + @package.tags.with_name(tags_to_destroy).delete_all if tags_to_destroy.any? + ::Gitlab::Database.bulk_insert(Packages::Tag.table_name, rows(tags_to_create)) if tags_to_create.any? + end + + private + + def existing_tags + strong_memoize(:existing_tags) do + @package.tag_names + end + end + + def rows(tags) + now = Time.zone.now + tags.map do |tag| + { + package_id: @package.id, + name: tag, + created_at: now, + updated_at: now + } + end + end + end +end diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb new file mode 100644 index 00000000000..20fcf0a7a32 --- /dev/null +++ b/app/uploaders/packages/package_file_uploader.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +class Packages::PackageFileUploader < GitlabUploader + extend Workhorse::UploadPath + include ObjectStorage::Concern + + storage_options Gitlab.config.packages + + after :store, :schedule_background_upload + + alias_method :upload, :model + + def filename + model.file_name + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + File.join(disk_hash[0..1], disk_hash[2..3], disk_hash, + 'packages', model.package.id.to_s, 'files', model.id.to_s) + end + + def disk_hash + @disk_hash ||= Digest::SHA2.hexdigest(model.package.project_id.to_s) + end +end diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml new file mode 100644 index 00000000000..da3e128ba32 --- /dev/null +++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml @@ -0,0 +1,6 @@ +- autodevops_help_url = help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters') +- help_link_start = ''.html_safe +- help_link_end = ''.html_safe + +%p + = s_('ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{help_link_start}read this first%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end } diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml index 24a74c59b97..31add011bfa 100644 --- a/app/views/clusters/clusters/_sidebar.html.haml +++ b/app/views/clusters/clusters/_sidebar.html.haml @@ -5,4 +5,4 @@ %p = clusterable.learn_more_link -= render_if_exists 'clusters/multiple_clusters_message' += render 'clusters/clusters/multiple_clusters_message' diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml index ccc3e734276..f14d50eaf71 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml @@ -1,5 +1,5 @@ #content - = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!") + = email_default_heading("#{sanitize_name(@resource.user.name)}, confirm your email address now!") %p Click the link below to confirm your email address (#{@resource.email}) #cta = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb index a3b28cb0b84..b91498ccfae 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb @@ -1,4 +1,4 @@ -<%= @resource.user.name %>, you've added an additional email! +<%= @resource.user.name %>, confirm your email address now! Use the link below to confirm your email address (<%= @resource.email %>) diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml index b15d60002fc..8ba6d62a4ff 100644 --- a/app/views/shared/boards/components/sidebar/_milestone.html.haml +++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml @@ -18,7 +18,8 @@ .dropdown %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" }, ":data-selected" => "milestoneTitle", - ":data-issuable-id" => "issue.iid" } + ":data-issuable-id" => "issue.iid", + ":data-project-id" => "issue.project_id" } = _("Milestone") = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 00113b2c2c0..bf39053301a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -45,7 +45,8 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed - if milestone.present? - = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] } + - milestone_title = milestone[:expired] ? _("%{milestone_name} (Past due)").html_safe % { milestone_name: milestone[:title] } : milestone[:title] + = link_to milestone_title, milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] } - else %span.no-value = _('None') -- cgit v1.2.3