Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-08 15:09:33 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-08 15:09:33 +0300
commitc52b72f5772d52e9fc85bd9f4e8b8497a6278c37 (patch)
treebbe0504b4c07a93e24db4a72785a847b2540eef8 /app
parent21341457a8c422d890a9ec30838b597dea565d62 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/api.js15
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue5
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue5
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue10
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js2
-rw-r--r--app/assets/javascripts/milestone_select.js151
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue19
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js4
-rw-r--r--app/assets/javascripts/static_site_editor/image_repository.js20
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/services/image_service.js9
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/image_service.js2
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss3
-rw-r--r--app/assets/stylesheets/pages/boards.scss26
-rw-r--r--app/assets/stylesheets/utilities.scss9
-rw-r--r--app/graphql/resolvers/milestone_resolver.rb6
-rw-r--r--app/helpers/clusters_helper.rb5
-rw-r--r--app/models/clusters/cluster.rb8
-rw-r--r--app/models/concerns/deployment_platform.rb22
-rw-r--r--app/models/packages.rb6
-rw-r--r--app/models/packages/build_info.rb6
-rw-r--r--app/models/packages/composer/metadatum.rb14
-rw-r--r--app/models/packages/conan.rb8
-rw-r--r--app/models/packages/conan/file_metadatum.rb32
-rw-r--r--app/models/packages/conan/metadatum.rb41
-rw-r--r--app/models/packages/dependency.rb47
-rw-r--r--app/models/packages/dependency_link.rb19
-rw-r--r--app/models/packages/go/module.rb93
-rw-r--r--app/models/packages/go/module_version.rb115
-rw-r--r--app/models/packages/maven.rb8
-rw-r--r--app/models/packages/maven/metadatum.rb28
-rw-r--r--app/models/packages/nuget.rb8
-rw-r--r--app/models/packages/nuget/dependency_link_metadatum.rb19
-rw-r--r--app/models/packages/nuget/metadatum.rb27
-rw-r--r--app/models/packages/package.rb195
-rw-r--r--app/models/packages/package_file.rb56
-rw-r--r--app/models/packages/pypi.rb8
-rw-r--r--app/models/packages/pypi/metadatum.rb19
-rw-r--r--app/models/packages/sem_ver.rb54
-rw-r--r--app/models/packages/tag.rb18
-rw-r--r--app/models/project.rb24
-rw-r--r--app/models/prometheus_metric.rb1
-rw-r--r--app/presenters/clusterable_presenter.rb14
-rw-r--r--app/services/clusters/create_service.rb11
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb46
-rw-r--r--app/services/metrics/dashboard/cluster_dashboard_service.rb31
-rw-r--r--app/services/metrics/dashboard/cluster_metrics_embed_service.rb37
-rw-r--r--app/services/packages/conan/create_package_file_service.rb31
-rw-r--r--app/services/packages/conan/create_package_service.rb19
-rw-r--r--app/services/packages/conan/search_service.rb58
-rw-r--r--app/services/packages/create_dependency_service.rb82
-rw-r--r--app/services/packages/create_package_file_service.rb22
-rw-r--r--app/services/packages/maven/create_package_service.rb28
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb41
-rw-r--r--app/services/packages/npm/create_package_service.rb91
-rw-r--r--app/services/packages/npm/create_tag_service.rb34
-rw-r--r--app/services/packages/nuget/create_dependency_service.rb71
-rw-r--r--app/services/packages/nuget/create_package_service.rb23
-rw-r--r--app/services/packages/nuget/metadata_extraction_service.rb106
-rw-r--r--app/services/packages/nuget/search_service.rb101
-rw-r--r--app/services/packages/nuget/sync_metadatum_service.rb50
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb125
-rw-r--r--app/services/packages/pypi/create_package_service.rb40
-rw-r--r--app/services/packages/remove_tag_service.rb16
-rw-r--r--app/services/packages/update_tags_service.rb41
-rw-r--r--app/uploaders/packages/package_file_uploader.rb30
-rw-r--r--app/views/clusters/clusters/_multiple_clusters_message.html.haml6
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml2
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.html.haml2
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.text.erb2
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml3
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml3
80 files changed, 2215 insertions, 200 deletions
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 {
<div>
<div
v-if="!isSwimlanesOn"
- class="boards-list w-100 py-3 px-2 text-nowrap"
+ class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
data-qa-selector="boards_list"
>
<board-column
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index fbe221041c1..8ff493ae8b1 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -25,10 +25,6 @@ export default {
type: Boolean,
required: true,
},
- milestonePath: {
- type: String,
- required: true,
- },
labelsPath: {
type: String,
required: true,
@@ -201,7 +197,6 @@ export default {
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
- :milestone-path="milestonePath"
:labels-path="labelsPath"
:enable-scoped-labels="enableScopedLabels"
:project-id="projectId"
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 80db9930259..56fb4f1a6c4 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -36,10 +36,6 @@ export default {
type: Object,
required: true,
},
- milestonePath: {
- type: String,
- required: true,
- },
throttleDuration: {
type: Number,
default: 200,
@@ -335,7 +331,6 @@ export default {
<board-form
v-if="currentPage"
- :milestone-path="milestonePath"
:labels-path="labelsPath"
:project-id="projectId"
:group-id="groupId"
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index a42e691dcf3..8b0d836f27a 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -17,10 +17,6 @@ export default {
type: Number,
required: true,
},
- milestonePath: {
- type: String,
- required: true,
- },
labelPath: {
type: String,
required: true,
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index 20344b66140..fb2d7b6dbc5 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -38,10 +38,6 @@ export default {
type: Number,
required: true,
},
- milestonePath: {
- type: String,
- required: true,
- },
labelPath: {
type: String,
required: true,
@@ -149,11 +145,7 @@ export default {
class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
>
<div class="add-issues-container d-flex flex-column m-auto rounded">
- <modal-header
- :project-id="projectId"
- :milestone-path="milestonePath"
- :label-path="labelPath"
- />
+ <modal-header :project-id="projectId" :label-path="labelPath" />
<modal-list
v-if="!loading && showList && !filterLoading"
:issue-link-base="issueLinkBase"
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index 73d37459bfe..51bb72b7657 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -27,7 +27,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(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
+ milestoneExpiredLinkTemplate = template(
+ '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %> (Past due)</a>',
+ );
milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
}
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 => `
- <li data-milestone-id="${escape(milestone.name)}">
+ 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 `
+ <li data-milestone-id="${escape(milestoneName)}">
<a href='#' class='dropdown-menu-milestone-link'>
- ${escape(milestone.title)}
+ ${milestoneDisplayName}
</a>
</li>
- `,
+ `;
+ },
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"
/>
<unsaved-changes-confirm-dialog :modified="isModified" />
<publish-toolbar
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 947347922f2..49db9ab7ca5 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -19,3 +19,5 @@ export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
+
+export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/';
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 6c4e3a4d973..0cb26f88785 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -3,10 +3,10 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
- { input: { project: projectId, username, sourcePath, content } },
+ { input: { project: projectId, username, sourcePath, content, images } },
{ cache },
) => {
- 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: ![<any-string-except-newline>](<path>)
+
+ 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"
/>
- <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
</div>
</template>
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 = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
+- help_link_end = '</a>'.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')