Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
ecf2b5b604
commit
43c14d2d92
|
@ -137,7 +137,7 @@ Dangerfile @gl-quality/eng-prod
|
||||||
/app/assets/javascripts/notes @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/notes @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/merge_conflicts @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/merge_conflicts @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/mr_notes @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/mr_notes @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/mr_popover @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/issuable/popover @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/vue_merge_request_widget @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/vue_merge_request_widget @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/merge_request.js @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/merge_request.js @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
/app/assets/javascripts/merge_request_tabs.js @viktomas @jboyson @iamphill @thomasrandolph
|
/app/assets/javascripts/merge_request_tabs.js @viktomas @jboyson @iamphill @thomasrandolph
|
||||||
|
|
|
@ -24,11 +24,11 @@ $.fn.renderGFM = function renderGFM() {
|
||||||
highlightCurrentUser(this.find('.gfm-project_member').get());
|
highlightCurrentUser(this.find('.gfm-project_member').get());
|
||||||
initUserPopovers(this.find('.js-user-link').get());
|
initUserPopovers(this.find('.js-user-link').get());
|
||||||
|
|
||||||
const mrPopoverElements = this.find('.gfm-merge_request').get();
|
const issuablePopoverElements = this.find('.gfm-merge_request').get();
|
||||||
if (mrPopoverElements.length) {
|
if (issuablePopoverElements.length) {
|
||||||
import(/* webpackChunkName: 'MrPopoverBundle' */ '~/mr_popover')
|
import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover')
|
||||||
.then(({ default: initMRPopovers }) => {
|
.then(({ default: initIssuablePopovers }) => {
|
||||||
initMRPopovers(mrPopoverElements);
|
initIssuablePopovers(issuablePopoverElements);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,7 +130,7 @@ export default {
|
||||||
v-gl-tooltip.bottom
|
v-gl-tooltip.bottom
|
||||||
class="gl-ml-3"
|
class="gl-ml-3"
|
||||||
:is-deleting="isDeleting"
|
:is-deleting="isDeleting"
|
||||||
button-variant="warning"
|
button-variant="default"
|
||||||
button-icon="archive"
|
button-icon="archive"
|
||||||
button-category="secondary"
|
button-category="secondary"
|
||||||
:title="s__('DesignManagement|Archive design')"
|
:title="s__('DesignManagement|Archive design')"
|
||||||
|
|
|
@ -6,8 +6,8 @@ import MRPopover from './components/mr_popover.vue';
|
||||||
let renderedPopover;
|
let renderedPopover;
|
||||||
let renderFn;
|
let renderFn;
|
||||||
|
|
||||||
const handleUserPopoverMouseOut = ({ target }) => {
|
const handleIssuablePopoverMouseOut = ({ target }) => {
|
||||||
target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
|
target.removeEventListener('mouseleave', handleIssuablePopoverMouseOut);
|
||||||
|
|
||||||
if (renderFn) {
|
if (renderFn) {
|
||||||
clearTimeout(renderFn);
|
clearTimeout(renderFn);
|
||||||
|
@ -22,9 +22,11 @@ const handleUserPopoverMouseOut = ({ target }) => {
|
||||||
* Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
|
* Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
|
||||||
* loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
|
* loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
|
||||||
*/
|
*/
|
||||||
const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => {
|
const handleIssuablePopoverMount = ({ apolloProvider, projectPath, title, iid }) => ({
|
||||||
|
target,
|
||||||
|
}) => {
|
||||||
// Add listener to actually remove it again
|
// Add listener to actually remove it again
|
||||||
target.addEventListener('mouseleave', handleUserPopoverMouseOut);
|
target.addEventListener('mouseleave', handleIssuablePopoverMouseOut);
|
||||||
|
|
||||||
renderFn = setTimeout(() => {
|
renderFn = setTimeout(() => {
|
||||||
const MRPopoverComponent = Vue.extend(MRPopover);
|
const MRPopoverComponent = Vue.extend(MRPopover);
|
||||||
|
@ -33,7 +35,7 @@ const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) =>
|
||||||
target,
|
target,
|
||||||
projectPath,
|
projectPath,
|
||||||
mergeRequestIID: iid,
|
mergeRequestIID: iid,
|
||||||
mergeRequestTitle: mrTitle,
|
mergeRequestTitle: title,
|
||||||
},
|
},
|
||||||
apolloProvider,
|
apolloProvider,
|
||||||
});
|
});
|
||||||
|
@ -43,22 +45,22 @@ const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (elements) => {
|
export default (elements) => {
|
||||||
const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')];
|
if (elements.length > 0) {
|
||||||
if (mrLinks.length > 0) {
|
|
||||||
Vue.use(VueApollo);
|
Vue.use(VueApollo);
|
||||||
|
|
||||||
const apolloProvider = new VueApollo({
|
const apolloProvider = new VueApollo({
|
||||||
defaultClient: createDefaultClient(),
|
defaultClient: createDefaultClient(),
|
||||||
});
|
});
|
||||||
const listenerAddedAttr = 'data-mr-listener-added';
|
const listenerAddedAttr = 'data-popover-listener-added';
|
||||||
|
|
||||||
mrLinks.forEach((el) => {
|
elements.forEach((el) => {
|
||||||
const { projectPath, mrTitle, iid } = el.dataset;
|
const { projectPath, iid } = el.dataset;
|
||||||
|
const title = el.dataset.mrTitle || el.title;
|
||||||
|
|
||||||
if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) {
|
if (!el.getAttribute(listenerAddedAttr) && projectPath && title && iid) {
|
||||||
el.addEventListener(
|
el.addEventListener(
|
||||||
'mouseenter',
|
'mouseenter',
|
||||||
handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }),
|
handleIssuablePopoverMount({ apolloProvider, projectPath, title, iid }),
|
||||||
);
|
);
|
||||||
el.setAttribute(listenerAddedAttr, true);
|
el.setAttribute(listenerAddedAttr, true);
|
||||||
}
|
}
|
|
@ -294,14 +294,20 @@ export default {
|
||||||
/>
|
/>
|
||||||
<emoji-picker
|
<emoji-picker
|
||||||
v-if="canAwardEmoji"
|
v-if="canAwardEmoji"
|
||||||
toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!"
|
toggle-class="note-action-button note-emoji-button btn-icon gl-shadow-none!"
|
||||||
data-testid="note-emoji-button"
|
data-testid="note-emoji-button"
|
||||||
@click="setAwardEmoji"
|
@click="setAwardEmoji"
|
||||||
>
|
>
|
||||||
<template #button-content>
|
<template #button-content>
|
||||||
<gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
|
<gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
|
||||||
<gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
|
<gl-icon
|
||||||
<gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
|
class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
|
||||||
|
name="smiley"
|
||||||
|
/>
|
||||||
|
<gl-icon
|
||||||
|
class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
|
||||||
|
name="smile"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</emoji-picker>
|
</emoji-picker>
|
||||||
<reply-button
|
<reply-button
|
||||||
|
|
|
@ -26,9 +26,9 @@ import {
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import { mapGetters, mapActions, mapState } from 'vuex';
|
import { mapGetters, mapActions, mapState } from 'vuex';
|
||||||
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
|
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
|
||||||
|
import '~/behaviors/markdown/render_gfm';
|
||||||
import axios from '~/lib/utils/axios_utils';
|
import axios from '~/lib/utils/axios_utils';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
import initMRPopovers from '~/mr_popover/';
|
|
||||||
import noteHeader from '~/notes/components/note_header.vue';
|
import noteHeader from '~/notes/components/note_header.vue';
|
||||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||||
import { spriteIcon } from '~/lib/utils/common_utils';
|
import { spriteIcon } from '~/lib/utils/common_utils';
|
||||||
|
@ -94,7 +94,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
|
$(this.$refs['gfm-content']).renderGFM();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
|
...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
|
||||||
|
@ -130,7 +130,7 @@ export default {
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="note-header">
|
<div class="note-header">
|
||||||
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
|
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
|
||||||
<span v-safe-html="actionTextHtml"></span>
|
<span ref="gfm-content" v-safe-html="actionTextHtml"></span>
|
||||||
<template
|
<template
|
||||||
v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
|
v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
|
||||||
#extra-controls
|
#extra-controls
|
||||||
|
|
|
@ -6,6 +6,7 @@ import workItemTitleSubscription from '../graphql/work_item_title.subscription.g
|
||||||
import WorkItemActions from './work_item_actions.vue';
|
import WorkItemActions from './work_item_actions.vue';
|
||||||
import WorkItemState from './work_item_state.vue';
|
import WorkItemState from './work_item_state.vue';
|
||||||
import WorkItemTitle from './work_item_title.vue';
|
import WorkItemTitle from './work_item_title.vue';
|
||||||
|
import WorkItemLinks from './work_item_links/work_item_links.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -15,6 +16,7 @@ export default {
|
||||||
WorkItemActions,
|
WorkItemActions,
|
||||||
WorkItemTitle,
|
WorkItemTitle,
|
||||||
WorkItemState,
|
WorkItemState,
|
||||||
|
WorkItemLinks,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
workItemId: {
|
workItemId: {
|
||||||
|
@ -105,6 +107,7 @@ export default {
|
||||||
@error="error = $event"
|
@error="error = $event"
|
||||||
@updated="$emit('workItemUpdated')"
|
@updated="$emit('workItemUpdated')"
|
||||||
/>
|
/>
|
||||||
|
<work-item-links :work-item-id="workItem.id" />
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
<script>
|
||||||
|
import { GlButton } from '@gitlab/ui';
|
||||||
|
import { s__ } from '~/locale';
|
||||||
|
import WorkItemLinksForm from './work_item_links_form.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
GlButton,
|
||||||
|
WorkItemLinksForm,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
workItemId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isShownAddForm: false,
|
||||||
|
isOpen: false,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// Only used for children for now but should be extended later to support parents and siblings
|
||||||
|
isChildrenEmpty() {
|
||||||
|
return this.children.length === 0;
|
||||||
|
},
|
||||||
|
toggleIcon() {
|
||||||
|
return this.isOpen ? 'angle-up' : 'angle-down';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggle() {
|
||||||
|
this.isOpen = !this.isOpen;
|
||||||
|
},
|
||||||
|
toggleAddForm() {
|
||||||
|
this.isShownAddForm = !this.isShownAddForm;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
title: s__('WorkItem|Child items'),
|
||||||
|
emptyStateMessage: s__(
|
||||||
|
'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
|
||||||
|
),
|
||||||
|
addChildButtonLabel: s__('WorkItem|Add a child'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100">
|
||||||
|
<div
|
||||||
|
class="gl-p-4 gl-display-flex gl-justify-content-space-between"
|
||||||
|
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
|
||||||
|
>
|
||||||
|
<h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5>
|
||||||
|
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4">
|
||||||
|
<gl-button
|
||||||
|
category="tertiary"
|
||||||
|
:icon="toggleIcon"
|
||||||
|
data-testid="toggle-links"
|
||||||
|
@click="toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isOpen" class="gl-bg-gray-10 gl-p-4" data-testid="links-body">
|
||||||
|
<div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty">
|
||||||
|
<p>
|
||||||
|
{{ $options.i18n.emptyStateMessage }}
|
||||||
|
</p>
|
||||||
|
<gl-button
|
||||||
|
v-if="!isShownAddForm"
|
||||||
|
category="secondary"
|
||||||
|
variant="confirm"
|
||||||
|
data-testid="toggle-add-form"
|
||||||
|
@click="toggleAddForm"
|
||||||
|
>
|
||||||
|
{{ $options.i18n.addChildButtonLabel }}
|
||||||
|
</gl-button>
|
||||||
|
<work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script>
|
||||||
|
import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
GlForm,
|
||||||
|
GlFormInput,
|
||||||
|
GlButton,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
relatedWorkItem: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<gl-form @submit.prevent>
|
||||||
|
<gl-form-input v-model="relatedWorkItem" class="gl-mb-4" />
|
||||||
|
<gl-button type="submit" category="secondary" variant="confirm">
|
||||||
|
{{ s__('WorkItem|Add') }}
|
||||||
|
</gl-button>
|
||||||
|
<gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')">
|
||||||
|
{{ s__('WorkItem|Cancel') }}
|
||||||
|
</gl-button>
|
||||||
|
</gl-form>
|
||||||
|
</template>
|
|
@ -358,8 +358,8 @@
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
color: $gray-400;
|
color: $gray-500;
|
||||||
fill: $gray-400;
|
fill: $gray-500;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@include btn-svg;
|
@include btn-svg;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'digest/md5'
|
|
||||||
require 'uri'
|
require 'uri'
|
||||||
|
|
||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
|
|
|
@ -15,5 +15,18 @@ module Emails
|
||||||
email = user.notification_email_or_default
|
email = user.notification_email_or_default
|
||||||
mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
|
mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:)
|
||||||
|
admin = User.find(admin_id)
|
||||||
|
@user = User.find(user_id)
|
||||||
|
@max_project_downloads = max_project_downloads
|
||||||
|
@within_minutes = within_seconds / 60
|
||||||
|
|
||||||
|
Gitlab::I18n.with_locale(admin.preferred_language) do
|
||||||
|
email_with_layout(
|
||||||
|
to: admin.notification_email_or_default,
|
||||||
|
subject: subject(_("We've detected unusual activity")))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,11 +8,9 @@ module Emails
|
||||||
|
|
||||||
add_project_headers
|
add_project_headers
|
||||||
|
|
||||||
mail(to: recipient,
|
email_with_layout(
|
||||||
subject: auto_devops_disabled_subject(@project.name)) do |format|
|
to: recipient,
|
||||||
format.html { render layout: 'mailer' }
|
subject: auto_devops_disabled_subject(@project.name))
|
||||||
format.text { render layout: 'mailer' }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -94,10 +94,9 @@ module Emails
|
||||||
@project = Project.find(project_id)
|
@project = Project.find(project_id)
|
||||||
@results = results
|
@results = results
|
||||||
|
|
||||||
mail(to: @user.notification_email_for(@project.group), subject: subject('Imported issues')) do |format|
|
email_with_layout(
|
||||||
format.html { render layout: 'mailer' }
|
to: @user.notification_email_for(@project.group),
|
||||||
format.text { render layout: 'mailer' }
|
subject: subject('Imported issues'))
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def issues_csv_email(user, project, csv_data, export_status)
|
def issues_csv_email(user, project, csv_data, export_status)
|
||||||
|
@ -110,10 +109,9 @@ module Emails
|
||||||
|
|
||||||
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
|
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
|
||||||
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
|
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
|
||||||
mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format|
|
email_with_layout(
|
||||||
format.html { render layout: 'mailer' }
|
to: user.notification_email_for(@project.group),
|
||||||
format.text { render layout: 'mailer' }
|
subject: subject("Exported issues"))
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -21,7 +21,7 @@ module Emails
|
||||||
|
|
||||||
user = User.find(recipient_id)
|
user = User.find(recipient_id)
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: user.notification_email_for(notification_group),
|
to: user.notification_email_for(notification_group),
|
||||||
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
|
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
|
||||||
end
|
end
|
||||||
|
@ -32,7 +32,7 @@ module Emails
|
||||||
|
|
||||||
return unless member_exists?
|
return unless member_exists?
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: member.user.notification_email_for(notification_group),
|
to: member.user.notification_email_for(notification_group),
|
||||||
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted"))
|
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted"))
|
||||||
end
|
end
|
||||||
|
@ -47,7 +47,7 @@ module Emails
|
||||||
|
|
||||||
human_name = @source_hidden ? 'Hidden' : member_source.human_name
|
human_name = @source_hidden ? 'Hidden' : member_source.human_name
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: user.notification_email_for(notification_group),
|
to: user.notification_email_for(notification_group),
|
||||||
subject: subject("Access to the #{human_name} #{member_source.model_name.singular} was denied"))
|
subject: subject("Access to the #{human_name} #{member_source.model_name.singular} was denied"))
|
||||||
end
|
end
|
||||||
|
@ -83,7 +83,7 @@ module Emails
|
||||||
|
|
||||||
subject_line = subjects[reminder_index] % { inviter: member.created_by.name }
|
subject_line = subjects[reminder_index] % { inviter: member.created_by.name }
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
layout: 'unknown_user_mailer',
|
layout: 'unknown_user_mailer',
|
||||||
to: member.invite_email,
|
to: member.invite_email,
|
||||||
subject: subject(subject_line)
|
subject: subject(subject_line)
|
||||||
|
@ -97,7 +97,7 @@ module Emails
|
||||||
return unless member_exists?
|
return unless member_exists?
|
||||||
return unless member.created_by
|
return unless member.created_by
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: member.created_by.notification_email_for(notification_group),
|
to: member.created_by.notification_email_for(notification_group),
|
||||||
subject: subject('Invitation accepted'))
|
subject: subject('Invitation accepted'))
|
||||||
end
|
end
|
||||||
|
@ -111,7 +111,7 @@ module Emails
|
||||||
|
|
||||||
user = User.find(created_by_id)
|
user = User.find(created_by_id)
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: user.notification_email_for(notification_group),
|
to: user.notification_email_for(notification_group),
|
||||||
subject: subject('Invitation declined'))
|
subject: subject('Invitation declined'))
|
||||||
end
|
end
|
||||||
|
@ -128,7 +128,7 @@ module Emails
|
||||||
_('Group membership expiration date removed')
|
_('Group membership expiration date removed')
|
||||||
end
|
end
|
||||||
|
|
||||||
member_email_with_layout(
|
email_with_layout(
|
||||||
to: member.user.notification_email_for(notification_group),
|
to: member.user.notification_email_for(notification_group),
|
||||||
subject: subject(subject))
|
subject: subject(subject))
|
||||||
end
|
end
|
||||||
|
@ -176,13 +176,6 @@ module Emails
|
||||||
def member_source_class
|
def member_source_class
|
||||||
@member_source_type.classify.constantize
|
@member_source_type.classify.constantize
|
||||||
end
|
end
|
||||||
|
|
||||||
def member_email_with_layout(to:, subject:, layout: 'mailer')
|
|
||||||
mail(to: to, subject: subject) do |format|
|
|
||||||
format.html { render layout: layout }
|
|
||||||
format.text { render layout: layout }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -149,10 +149,9 @@ module Emails
|
||||||
|
|
||||||
filename = "#{project.full_path.parameterize}_merge_requests_#{Date.current.iso8601}.csv"
|
filename = "#{project.full_path.parameterize}_merge_requests_#{Date.current.iso8601}.csv"
|
||||||
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
|
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
|
||||||
mail(to: user.notification_email_for(@project.group), subject: subject("Exported merge requests")) do |format|
|
email_with_layout(
|
||||||
format.html { render layout: 'mailer' }
|
to: user.notification_email_for(@project.group),
|
||||||
format.text { render layout: 'mailer' }
|
subject: subject("Exported merge requests"))
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def approved_merge_request_email(recipient_id, merge_request_id, approved_by_user_id, reason = nil)
|
def approved_merge_request_email(recipient_id, merge_request_id, approved_by_user_id, reason = nil)
|
||||||
|
|
|
@ -30,11 +30,9 @@ module Emails
|
||||||
|
|
||||||
add_headers
|
add_headers
|
||||||
|
|
||||||
mail(to: recipient,
|
email_with_layout(
|
||||||
subject: subject(pipeline_subject(status))) do |format|
|
to: recipient,
|
||||||
format.html { render layout: 'mailer' }
|
subject: subject(pipeline_subject(status)))
|
||||||
format.text { render layout: 'mailer' }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_headers
|
def add_headers
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Emails
|
||||||
@user = user
|
@user = user
|
||||||
@recipient = recipient
|
@recipient = recipient
|
||||||
|
|
||||||
profile_email_with_layout(
|
email_with_layout(
|
||||||
to: recipient.notification_email_or_default,
|
to: recipient.notification_email_or_default,
|
||||||
subject: subject(_("GitLab Account Request")))
|
subject: subject(_("GitLab Account Request")))
|
||||||
end
|
end
|
||||||
|
@ -21,7 +21,7 @@ module Emails
|
||||||
def user_admin_rejection_email(name, email)
|
def user_admin_rejection_email(name, email)
|
||||||
@name = name
|
@name = name
|
||||||
|
|
||||||
profile_email_with_layout(
|
email_with_layout(
|
||||||
to: email,
|
to: email,
|
||||||
subject: subject(_("GitLab account request rejected")))
|
subject: subject(_("GitLab account request rejected")))
|
||||||
end
|
end
|
||||||
|
@ -29,7 +29,7 @@ module Emails
|
||||||
def user_deactivated_email(name, email)
|
def user_deactivated_email(name, email)
|
||||||
@name = name
|
@name = name
|
||||||
|
|
||||||
profile_email_with_layout(
|
email_with_layout(
|
||||||
to: email,
|
to: email,
|
||||||
subject: subject(_('Your account has been deactivated')))
|
subject: subject(_('Your account has been deactivated')))
|
||||||
end
|
end
|
||||||
|
@ -125,7 +125,7 @@ module Emails
|
||||||
@target_url = edit_profile_password_url
|
@target_url = edit_profile_password_url
|
||||||
|
|
||||||
Gitlab::I18n.with_locale(@user.preferred_language) do
|
Gitlab::I18n.with_locale(@user.preferred_language) do
|
||||||
profile_email_with_layout(
|
email_with_layout(
|
||||||
to: @user.notification_email_or_default,
|
to: @user.notification_email_or_default,
|
||||||
subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
|
subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
|
||||||
end
|
end
|
||||||
|
@ -151,15 +151,6 @@ module Emails
|
||||||
mail(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
|
mail(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def profile_email_with_layout(to:, subject:, layout: 'mailer')
|
|
||||||
mail(to: to, subject: subject) do |format|
|
|
||||||
format.html { render layout: layout }
|
|
||||||
format.text { render layout: layout }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -75,11 +75,9 @@ module Emails
|
||||||
subject_text = "Action required: Project #{project.name} is scheduled to be deleted on " \
|
subject_text = "Action required: Project #{project.name} is scheduled to be deleted on " \
|
||||||
"#{deletion_date} due to inactivity"
|
"#{deletion_date} due to inactivity"
|
||||||
|
|
||||||
mail(to: user.notification_email_for(project.group),
|
email_with_layout(
|
||||||
subject: subject(subject_text)) do |format|
|
to: user.notification_email_for(project.group),
|
||||||
format.html { render layout: 'mailer' }
|
subject: subject(subject_text))
|
||||||
format.text { render layout: 'mailer' }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -222,6 +222,13 @@ class Notify < ApplicationMailer
|
||||||
headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',')
|
headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',')
|
||||||
@unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification)
|
@unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def email_with_layout(to:, subject:, layout: 'mailer')
|
||||||
|
mail(to: to, subject: subject) do |format|
|
||||||
|
format.html { render layout: layout }
|
||||||
|
format.text { render layout: layout }
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Notify.prepend_mod_with('Notify')
|
Notify.prepend_mod_with('Notify')
|
||||||
|
|
|
@ -205,6 +205,10 @@ class NotifyPreview < ActionMailer::Preview
|
||||||
Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message
|
Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_auto_banned_email
|
||||||
|
::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def project
|
def project
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'digest/md5'
|
|
||||||
|
|
||||||
class Key < ApplicationRecord
|
class Key < ApplicationRecord
|
||||||
include AfterCommitQueue
|
include AfterCommitQueue
|
||||||
include Sortable
|
include Sortable
|
||||||
|
|
|
@ -121,16 +121,16 @@ module MergeRequests
|
||||||
override :handle_quick_actions
|
override :handle_quick_actions
|
||||||
def handle_quick_actions(merge_request)
|
def handle_quick_actions(merge_request)
|
||||||
super
|
super
|
||||||
handle_wip_event(merge_request)
|
handle_draft_event(merge_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_wip_event(merge_request)
|
def handle_draft_event(merge_request)
|
||||||
if wip_event = params.delete(:wip_event)
|
if draft_event = params.delete(:wip_event)
|
||||||
# We update the title that is provided in the params or we use the mr title
|
# We update the title that is provided in the params or we use the mr title
|
||||||
title = params[:title] || merge_request.title
|
title = params[:title] || merge_request.title
|
||||||
params[:title] = case wip_event
|
params[:title] = case draft_event
|
||||||
when 'wip' then MergeRequest.wip_title(title)
|
when 'wip' then MergeRequest.draft_title(title)
|
||||||
when 'unwip' then MergeRequest.wipless_title(title)
|
when 'unwip' then MergeRequest.draftless_title(title)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -46,7 +46,7 @@ module MergeRequests
|
||||||
:source_branch_ref,
|
:source_branch_ref,
|
||||||
:source_project,
|
:source_project,
|
||||||
:compare_commits,
|
:compare_commits,
|
||||||
:wip_title,
|
:draft_title,
|
||||||
:description,
|
:description,
|
||||||
:first_multiline_commit,
|
:first_multiline_commit,
|
||||||
:errors,
|
:errors,
|
||||||
|
@ -206,7 +206,7 @@ module MergeRequests
|
||||||
def set_draft_title_if_needed
|
def set_draft_title_if_needed
|
||||||
return unless compare_commits.empty? || Gitlab::Utils.to_boolean(params[:draft])
|
return unless compare_commits.empty? || Gitlab::Utils.to_boolean(params[:draft])
|
||||||
|
|
||||||
merge_request.title = wip_title
|
merge_request.title = draft_title
|
||||||
end
|
end
|
||||||
|
|
||||||
# When your branch name starts with an iid followed by a dash this pattern will be
|
# When your branch name starts with an iid followed by a dash this pattern will be
|
||||||
|
|
|
@ -43,3 +43,5 @@ module MergeRequests
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
MergeRequests::ReloadMergeHeadDiffService.prepend_mod
|
||||||
|
|
|
@ -24,12 +24,14 @@ module Packages
|
||||||
file.write(content)
|
file.write(content)
|
||||||
file.flush
|
file.flush
|
||||||
|
|
||||||
|
md5 = Gitlab::FIPS.enabled? ? nil : Digest::MD5.hexdigest(content)
|
||||||
|
|
||||||
package.package_files.create!(
|
package.package_files.create!(
|
||||||
file: file,
|
file: file,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
file_name: "#{gemspec.name}.gemspec",
|
file_name: "#{gemspec.name}.gemspec",
|
||||||
file_sha1: Digest::SHA1.hexdigest(content),
|
file_sha1: Digest::SHA1.hexdigest(content),
|
||||||
file_md5: Digest::MD5.hexdigest(content),
|
file_md5: md5,
|
||||||
file_sha256: Digest::SHA256.hexdigest(content)
|
file_sha256: Digest::SHA256.hexdigest(content)
|
||||||
)
|
)
|
||||||
ensure
|
ensure
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
%button.gl-button.btn.btn-default.award-control.has-tooltip.js-add-award{ type: 'button',
|
%button.gl-button.btn.btn-default.award-control.has-tooltip.js-add-award{ type: 'button',
|
||||||
'aria-label': _('Add reaction'),
|
'aria-label': _('Add reaction'),
|
||||||
data: { title: _('Add reaction') } }
|
data: { title: _('Add reaction') } }
|
||||||
%span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
|
%span{ class: "award-control-icon award-control-icon-neutral gl-icon" }= sprite_icon('slight-smile')
|
||||||
%span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
|
%span{ class: "award-control-icon award-control-icon-positive gl-icon" }= sprite_icon('smiley')
|
||||||
%span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
|
%span{ class: "award-control-icon award-control-icon-super-positive gl-icon" }= sprite_icon('smile')
|
||||||
= yield
|
= yield
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
|
||||||
|
- link_end = '</a>'.html_safe
|
||||||
|
= email_default_heading(_("We've detected some unusual activity"))
|
||||||
|
%p
|
||||||
|
= _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes }
|
||||||
|
%p
|
||||||
|
= _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end }
|
||||||
|
%p
|
||||||
|
= _('You can adjust rules on auto-banning %{link_start}here%{link_end}.').html_safe % { link_start: link_start % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') }, link_end: link_end }
|
|
@ -0,0 +1,7 @@
|
||||||
|
<%= _("We've detected some unusual activity") %>
|
||||||
|
|
||||||
|
<%= _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } %>
|
||||||
|
|
||||||
|
<%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %>
|
||||||
|
|
||||||
|
<%= _('You can adjust rules on auto-banning here: %{url}.') % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') } %>
|
|
@ -9,15 +9,14 @@
|
||||||
- if can?(current_user, :award_emoji, note)
|
- if can?(current_user, :award_emoji, note)
|
||||||
- if note.emoji_awardable?
|
- if note.emoji_awardable?
|
||||||
.note-actions-item
|
.note-actions-item
|
||||||
= button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary btn-transparent", data: { position: 'right', container: 'body' } do
|
= button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary", data: { position: 'right', container: 'body' } do
|
||||||
= sprite_icon('slight-smile', css_class: 'link-highlight award-control-icon-neutral gl-button-icon gl-icon gl-text-gray-400')
|
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral gl-button-icon gl-icon')
|
||||||
= sprite_icon('smiley', css_class: 'link-highlight award-control-icon-positive gl-button-icon gl-icon gl-left-3!')
|
= sprite_icon('smiley', css_class: 'award-control-icon-positive gl-button-icon gl-icon gl-left-3!')
|
||||||
= sprite_icon('smile', css_class: 'link-highlight award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ')
|
= sprite_icon('smile', css_class: 'award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ')
|
||||||
|
|
||||||
- if note_editable
|
- if note_editable
|
||||||
.note-actions-item.gl-ml-0
|
.note-actions-item.gl-ml-0
|
||||||
= button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-px-2!', data: { container: 'body', qa_selector: 'edit_comment_button' } do
|
= button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { container: 'body', qa_selector: 'edit_comment_button' } do
|
||||||
%span.link-highlight
|
= sprite_icon('pencil', css_class: 'gl-button-icon gl-icon')
|
||||||
= sprite_icon('pencil', css_class: 'gl-button-icon gl-icon gl-text-gray-400 s16')
|
|
||||||
|
|
||||||
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
|
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
- if note_editable || !is_current_user
|
- if note_editable || !is_current_user
|
||||||
%div{ class: "dropdown more-actions note-actions-item gl-ml-0!" }
|
%div{ class: "dropdown more-actions note-actions-item gl-ml-0!" }
|
||||||
= button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-pl-2! gl-pr-0!', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do
|
= button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do
|
||||||
= sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon gl-text-gray-400')
|
= sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon')
|
||||||
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
|
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
|
||||||
%li
|
%li
|
||||||
= clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
|
= clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
- if current_user
|
- if current_user
|
||||||
- if note.emoji_awardable?
|
- if note.emoji_awardable?
|
||||||
.note-actions-item
|
.note-actions-item
|
||||||
= link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do
|
= link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary", data: { position: 'right' } do
|
||||||
%span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
|
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral gl-button-icon gl-icon')
|
||||||
%span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
|
= sprite_icon('smiley', css_class: 'award-control-icon-positive gl-button-icon gl-icon gl-left-3!')
|
||||||
%span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile')
|
= sprite_icon('smile', css_class: 'award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ')
|
||||||
|
|
||||||
- if note_editable
|
- if note_editable
|
||||||
.note-actions-item
|
.note-actions-item.gl-ml-0
|
||||||
= button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip gl-button btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do
|
= button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip gl-button btn btn-default-tertiary btn-icon', data: { container: 'body', qa_selector: 'edit_comment_button' } do
|
||||||
%span.link-highlight
|
= sprite_icon('pencil')
|
||||||
= custom_icon('icon_pencil')
|
|
||||||
|
|
||||||
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
|
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'settingslogic'
|
require 'settingslogic'
|
||||||
require 'digest/md5'
|
|
||||||
|
|
||||||
class Settings < Settingslogic
|
class Settings < Settingslogic
|
||||||
source ENV.fetch('GITLAB_CONFIG') { Pathname.new(File.expand_path('..', __dir__)).join('config/gitlab.yml') }
|
source ENV.fetch('GITLAB_CONFIG') { Pathname.new(File.expand_path('..', __dir__)).join('config/gitlab.yml') }
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'digest/md5'
|
|
||||||
|
|
||||||
REVIEW_ROULETTE_SECTION = <<MARKDOWN
|
REVIEW_ROULETTE_SECTION = <<MARKDOWN
|
||||||
## Reviewer roulette
|
## Reviewer roulette
|
||||||
MARKDOWN
|
MARKDOWN
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'digest/md5'
|
|
||||||
|
|
||||||
class Gitlab::Seeder::GroupLabels
|
class Gitlab::Seeder::GroupLabels
|
||||||
def initialize(group, label_per_group: 10)
|
def initialize(group, label_per_group: 10)
|
||||||
@group = group
|
@group = group
|
||||||
|
|
|
@ -4,13 +4,13 @@ group: Pipeline Execution
|
||||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pipeline triggers API **(FREE)**
|
# Pipeline trigger tokens API **(FREE)**
|
||||||
|
|
||||||
You can read more about [triggering pipelines through the API](../ci/triggers/index.md).
|
You can read more about [triggering pipelines through the API](../ci/triggers/index.md).
|
||||||
|
|
||||||
## List project triggers
|
## List project trigger tokens
|
||||||
|
|
||||||
Get a list of project's build triggers.
|
Get a list of a project's pipeline trigger tokens.
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
GET /projects/:id/triggers
|
GET /projects/:id/triggers
|
||||||
|
@ -41,9 +41,9 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
|
||||||
The trigger token is displayed in full if the trigger token was created by the authenticated
|
The trigger token is displayed in full if the trigger token was created by the authenticated
|
||||||
user. Trigger tokens created by other users are shortened to four characters.
|
user. Trigger tokens created by other users are shortened to four characters.
|
||||||
|
|
||||||
## Get trigger details
|
## Get trigger token details
|
||||||
|
|
||||||
Get details of project's build trigger.
|
Get details of a project's pipeline trigger.
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
GET /projects/:id/triggers/:trigger_id
|
GET /projects/:id/triggers/:trigger_id
|
||||||
|
@ -70,9 +70,9 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create a project trigger
|
## Create a trigger token
|
||||||
|
|
||||||
Create a trigger for a project.
|
Create a pipeline trigger for a project.
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
POST /projects/:id/triggers
|
POST /projects/:id/triggers
|
||||||
|
@ -100,9 +100,9 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Update a project trigger
|
## Update a project trigger token
|
||||||
|
|
||||||
Update a trigger for a project.
|
Update a pipeline trigger token for a project.
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
PUT /projects/:id/triggers/:trigger_id
|
PUT /projects/:id/triggers/:trigger_id
|
||||||
|
@ -131,9 +131,9 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" \
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remove a project trigger
|
## Remove a project trigger token
|
||||||
|
|
||||||
Remove a project's build trigger.
|
Remove a project's pipeline trigger token.
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
DELETE /projects/:id/triggers/:trigger_id
|
DELETE /projects/:id/triggers/:trigger_id
|
||||||
|
@ -147,3 +147,78 @@ DELETE /projects/:id/triggers/:trigger_id
|
||||||
```shell
|
```shell
|
||||||
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/triggers/5"
|
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/triggers/5"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Trigger a pipeline with a token
|
||||||
|
|
||||||
|
Trigger a pipeline by using a pipeline [trigger token](../ci/triggers/index.md#create-a-trigger-token)
|
||||||
|
or a [CI/CD job token](../ci/jobs/ci_job_token.md) for authentication.
|
||||||
|
|
||||||
|
With a CI/CD job token, the [triggered pipeline is a multi-project pipeline](../ci/jobs/ci_job_token.md#trigger-a-multi-project-pipeline-by-using-a-cicd-job-token).
|
||||||
|
The job that authenticates the request becomes associated with the upstream pipeline,
|
||||||
|
which is visible on the [pipeline graph](../ci/pipelines/multi_project_pipelines.md#multi-project-pipeline-visualization).
|
||||||
|
|
||||||
|
If you use a trigger token in a job, the job is not associated with the upstream pipeline.
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
POST /projects/:id/trigger/pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported attributes:
|
||||||
|
|
||||||
|
| Attribute | Type | Required | Description |
|
||||||
|
|:------------|:---------------|:-----------------------|:---------------------|
|
||||||
|
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. |
|
||||||
|
| `ref` | string | **{check-circle}** Yes | The branch or tag to run the pipeline on. |
|
||||||
|
| `token` | string | **{check-circle}** Yes | The trigger token or CI/CD job token. |
|
||||||
|
| `variables` | array | **{dotted-circle}** No | An array containing the variables available in the pipeline, matching the structure `[{ 'key': 'UPLOAD_TO_S3', 'variable_type': 'file', 'value': 'true' }, {'key': 'TEST', 'value': 'test variable'}]`. If `variable_type` is excluded, it defaults to `env_var`. |
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl --request POST "https://gitlab.example.com/api/v4/projects/123/trigger/pipeline?token=2cb1840fb9dfc9fb0b7b1609cd29cb&ref=main"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 257,
|
||||||
|
"iid": 118,
|
||||||
|
"project_id": 21,
|
||||||
|
"sha": "91e2711a93e5d9e8dddfeb6d003b636b25bf6fc9",
|
||||||
|
"ref": "main",
|
||||||
|
"status": "created",
|
||||||
|
"source": "trigger",
|
||||||
|
"created_at": "2022-03-31T01:12:49.068Z",
|
||||||
|
"updated_at": "2022-03-31T01:12:49.068Z",
|
||||||
|
"web_url": "http://127.0.0.1:3000/test-group/test-project/-/pipelines/257",
|
||||||
|
"before_sha": "0000000000000000000000000000000000000000",
|
||||||
|
"tag": false,
|
||||||
|
"yaml_errors": null,
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "root",
|
||||||
|
"name": "Administrator",
|
||||||
|
"state": "active",
|
||||||
|
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
|
||||||
|
"web_url": "http://127.0.0.1:3000/root"
|
||||||
|
},
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"committed_at": null,
|
||||||
|
"duration": null,
|
||||||
|
"queued_duration": null,
|
||||||
|
"coverage": null,
|
||||||
|
"detailed_status": {
|
||||||
|
"icon": "status_created",
|
||||||
|
"text": "created",
|
||||||
|
"label": "created",
|
||||||
|
"group": "created",
|
||||||
|
"tooltip": "created",
|
||||||
|
"has_details": true,
|
||||||
|
"details_path": "/test-group/test-project/-/pipelines/257",
|
||||||
|
"illustration": null,
|
||||||
|
"favicon": "/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
@ -104,13 +104,12 @@ The job token scope is only for controlling access to private projects.
|
||||||
There is [a proposal](https://gitlab.com/groups/gitlab-org/-/epics/3559) to improve
|
There is [a proposal](https://gitlab.com/groups/gitlab-org/-/epics/3559) to improve
|
||||||
the feature with more strategic control of the access permissions.
|
the feature with more strategic control of the access permissions.
|
||||||
|
|
||||||
## Trigger a multi-project pipeline by using a CI job token
|
## Trigger a multi-project pipeline by using a CI/CD job token
|
||||||
|
|
||||||
> `CI_JOB_TOKEN` for multi-project pipelines was [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/31573) from GitLab Premium to GitLab Free in 12.4.
|
> `CI_JOB_TOKEN` for multi-project pipelines was [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/31573) from GitLab Premium to GitLab Free in 12.4.
|
||||||
|
|
||||||
You can use the `CI_JOB_TOKEN` to trigger [multi-project pipelines](../pipelines/multi_project_pipelines.md)
|
You can use the `CI_JOB_TOKEN` to [trigger multi-project pipelines](../../api/pipeline_triggers.md#trigger-a-pipeline-with-a-token)
|
||||||
from a CI/CD job. A pipeline triggered this way creates a dependent pipeline relation
|
from a CI/CD job.
|
||||||
that is visible on the [pipeline graph](../pipelines/multi_project_pipelines.md#multi-project-pipeline-visualization).
|
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
|
|
|
@ -72,11 +72,16 @@ For configuration information, see
|
||||||
|
|
||||||
### Git operations using SSH
|
### Git operations using SSH
|
||||||
|
|
||||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78373) in GitLab 14.7.
|
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78373) in GitLab 14.7 [with a flag](../administration/feature_flags.md) named `rate_limit_gitlab_shell`. Disabled by default.
|
||||||
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79419) in GitLab 14.8.
|
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79419) in GitLab 14.8.
|
||||||
|
|
||||||
GitLab rate limits Git operations using SSH by user account and project. If a request from a user for a Git operation
|
FLAG:
|
||||||
on a project exceeds the rate limit, GitLab drops further connection requests from that user for the project.
|
On self-managed GitLab, by default this feature is available. To disable the feature, ask an administrator to
|
||||||
|
[disable the feature flag](../administration/feature_flags.md) named `rate_limit_gitlab_shell`. On GitLab.com, this feature
|
||||||
|
is available.
|
||||||
|
|
||||||
|
GitLab applies rate limits to Git operations that use SSH by user account and project. When the rate limit is exceeded, GitLab rejects
|
||||||
|
further connection requests from that user for the project.
|
||||||
|
|
||||||
The rate limit applies at the Git command ([plumbing](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain)) level.
|
The rate limit applies at the Git command ([plumbing](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain)) level.
|
||||||
Each command has a rate limit of 600 per minute. For example:
|
Each command has a rate limit of 600 per minute. For example:
|
||||||
|
@ -86,9 +91,8 @@ Each command has a rate limit of 600 per minute. For example:
|
||||||
|
|
||||||
Because the same commands are shared by `git-upload-pack`, `git pull`, and `git clone`, they share a rate limit.
|
Because the same commands are shared by `git-upload-pack`, `git pull`, and `git clone`, they share a rate limit.
|
||||||
|
|
||||||
The requests/minute threshold for this rate limit is not configurable. Self-managed customers can disable this
|
The requests per minute threshold for this rate limit is not configurable. Self-managed customers can disable this
|
||||||
rate limit by [disabling the feature flag](../administration/feature_flags.md#enable-or-disable-the-feature)
|
rate limit.
|
||||||
with `Feature.disable(:rate_limit_gitlab_shell)`.
|
|
||||||
|
|
||||||
### Repository archives
|
### Repository archives
|
||||||
|
|
||||||
|
|
|
@ -10,11 +10,6 @@ The [certificate-based Kubernetes integration with GitLab](../index.md)
|
||||||
was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8)
|
was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8)
|
||||||
in GitLab 14.5. To connect your clusters, use the [GitLab agent](../../../clusters/agent/index.md).
|
in GitLab 14.5. To connect your clusters, use the [GitLab agent](../../../clusters/agent/index.md).
|
||||||
|
|
||||||
<!-- TBA: (We need to resolve https://gitlab.com/gitlab-org/gitlab/-/issues/343660 before adding this line)
|
|
||||||
If you don't have a cluster yet, create one and connect it to GitLab through the agent.
|
|
||||||
You can also create a new cluster from GitLab using [Infrastructure as Code](../../iac/index.md#create-a-new-cluster-through-iac).
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Cluster levels (DEPRECATED)
|
## Cluster levels (DEPRECATED)
|
||||||
|
|
||||||
> [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5.
|
> [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5.
|
||||||
|
|
|
@ -32,8 +32,7 @@ To get started, choose the template that best suits your needs:
|
||||||
|
|
||||||
All templates:
|
All templates:
|
||||||
|
|
||||||
- Use the [GitLab-managed Terraform state](#gitlab-managed-terraform-state) as
|
- Use the [GitLab-managed Terraform state](terraform_state.md) as the Terraform state storage backend.
|
||||||
the Terraform state storage backend.
|
|
||||||
- Trigger four pipeline stages: `test`, `validate`, `build`, and `deploy`.
|
- Trigger four pipeline stages: `test`, `validate`, `build`, and `deploy`.
|
||||||
- Run Terraform commands: `test`, `validate`, `plan`, and `plan-json`. It also runs the `apply` only on the default branch.
|
- Run Terraform commands: `test`, `validate`, `plan`, and `plan-json`. It also runs the `apply` only on the default branch.
|
||||||
- Run the [Terraform SAST scanner](../../application_security/iac_scanning/index.md#configure-iac-scanning-manually).
|
- Run the [Terraform SAST scanner](../../application_security/iac_scanning/index.md#configure-iac-scanning-manually).
|
||||||
|
@ -89,37 +88,19 @@ To use a Terraform template:
|
||||||
# TF_ROOT: terraform/production
|
# TF_ROOT: terraform/production
|
||||||
```
|
```
|
||||||
|
|
||||||
1. (Optional) Override in your `.gitlab-ci.yml` file the attributes present
|
1. Optional. Override in your `.gitlab-ci.yml` file the attributes present
|
||||||
in the template you fetched to customize your configuration.
|
in the template you fetched to customize your configuration.
|
||||||
|
|
||||||
## GitLab-managed Terraform state
|
|
||||||
|
|
||||||
Use the [GitLab-managed Terraform state](terraform_state.md) to store state
|
|
||||||
files in local storage or in a remote store of your choice.
|
|
||||||
|
|
||||||
## Terraform module registry
|
|
||||||
|
|
||||||
Use GitLab as a [Terraform module registry](../../packages/terraform_module_registry/index.md)
|
|
||||||
to create and publish Terraform modules to a private registry.
|
|
||||||
|
|
||||||
## Terraform integration in merge requests
|
|
||||||
|
|
||||||
Use the [Terraform integration in merge requests](mr_integration.md)
|
|
||||||
to collaborate on Terraform code changes and Infrastructure-as-Code
|
|
||||||
workflows.
|
|
||||||
|
|
||||||
## The GitLab Terraform provider
|
|
||||||
|
|
||||||
The [GitLab Terraform provider](https://github.com/gitlabhq/terraform-provider-gitlab) is a Terraform plugin to facilitate
|
|
||||||
managing of GitLab resources such as users, groups, and projects. It is released separately from GitLab
|
|
||||||
and its documentation is available on [Terraform](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs).
|
|
||||||
|
|
||||||
## Create a new cluster through IaC
|
|
||||||
|
|
||||||
- Learn how to [create a new cluster on Amazon Elastic Kubernetes Service (EKS)](../clusters/connect/new_eks_cluster.md).
|
|
||||||
- Learn how to [create a new cluster on Google Kubernetes Engine (GKE)](../clusters/connect/new_gke_cluster.md).
|
|
||||||
|
|
||||||
## Related topics
|
## Related topics
|
||||||
|
|
||||||
- [Terraform images](https://gitlab.com/gitlab-org/terraform-images).
|
- View [the images that contain the `gitlab-terraform` shell script](https://gitlab.com/gitlab-org/terraform-images).
|
||||||
- [Troubleshooting](troubleshooting.md) issues with GitLab and Terraform.
|
- Use GitLab as a [Terraform module registry](../../packages/terraform_module_registry/index.md).
|
||||||
|
- To store state files in local storage or in a remote store, use the [GitLab-managed Terraform state](terraform_state.md).
|
||||||
|
- To collaborate on Terraform code changes and Infrastructure-as-Code workflows, use the
|
||||||
|
[Terraform integration in merge requests](mr_integration.md).
|
||||||
|
- To manage GitLab resources like users, groups, and projects, use the
|
||||||
|
[GitLab Terraform provider](https://github.com/gitlabhq/terraform-provider-gitlab). It is released separately from GitLab
|
||||||
|
and its documentation is available on [the Terraform docs site](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs).
|
||||||
|
- [Create a new cluster on Amazon Elastic Kubernetes Service (EKS)](../clusters/connect/new_eks_cluster.md).
|
||||||
|
- [Create a new cluster on Google Kubernetes Engine (GKE)](../clusters/connect/new_gke_cluster.md).
|
||||||
|
- [Troubleshoot](troubleshooting.md) issues with GitLab and Terraform.
|
||||||
|
|
|
@ -10,7 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
|
||||||
> - [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5.
|
> - [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5.
|
||||||
|
|
||||||
WARNING:
|
WARNING:
|
||||||
This feature was deprecated in GitLab 14.5. Use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac)
|
This feature was deprecated in GitLab 14.5. Use [Infrastructure as Code](../../infrastructure/iac/index.md)
|
||||||
to create new clusters.
|
to create new clusters.
|
||||||
|
|
||||||
Through GitLab, you can create new clusters and add existing clusters hosted on Amazon Elastic
|
Through GitLab, you can create new clusters and add existing clusters hosted on Amazon Elastic
|
||||||
|
@ -23,7 +23,7 @@ use the [GitLab agent](../../clusters/agent/index.md).
|
||||||
|
|
||||||
## Create a new EKS cluster
|
## Create a new EKS cluster
|
||||||
|
|
||||||
To create a new cluster from GitLab, use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac).
|
To create a new cluster from GitLab, use [Infrastructure as Code](../../infrastructure/iac/index.md).
|
||||||
|
|
||||||
### How to create a new cluster on EKS through cluster certificates (DEPRECATED)
|
### How to create a new cluster on EKS through cluster certificates (DEPRECATED)
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
|
||||||
|
|
||||||
WARNING:
|
WARNING:
|
||||||
This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0.
|
This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0.
|
||||||
To create and manage a new cluster use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac).
|
To create and manage a new cluster use [Infrastructure as Code](../../infrastructure/iac/index.md).
|
||||||
|
|
||||||
## Disable a cluster
|
## Disable a cluster
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
module Gitlab
|
module Gitlab
|
||||||
module Database
|
module Database
|
||||||
class ConsistencyChecker
|
class ConsistencyChecker
|
||||||
BATCH_SIZE = 1000
|
BATCH_SIZE = 500
|
||||||
MAX_BATCHES = 25
|
MAX_BATCHES = 20
|
||||||
MAX_RUNTIME = 30.seconds # must be less than the scheduling frequency of the ConsistencyCheck jobs
|
MAX_RUNTIME = 5.seconds # must be less than the scheduling frequency of the ConsistencyCheck jobs
|
||||||
|
|
||||||
delegate :monotonic_time, to: :'Gitlab::Metrics::System'
|
delegate :monotonic_time, to: :'Gitlab::Metrics::System'
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ module Gitlab
|
||||||
else
|
else
|
||||||
# Only show what is new in the source branch
|
# Only show what is new in the source branch
|
||||||
# compared to the target branch, not the other way
|
# compared to the target branch, not the other way
|
||||||
# around. The linex below with merge_base is
|
# around. The line below with merge_base is
|
||||||
# equivalent to diff with three dots (git diff
|
# equivalent to diff with three dots (git diff
|
||||||
# branch1...branch2) From the git documentation:
|
# branch1...branch2) From the git documentation:
|
||||||
# "git diff A...B" is equivalent to "git diff
|
# "git diff A...B" is equivalent to "git diff
|
||||||
|
|
|
@ -4,60 +4,6 @@ require 'logger'
|
||||||
|
|
||||||
namespace :gitlab do
|
namespace :gitlab do
|
||||||
namespace :pages do
|
namespace :pages do
|
||||||
desc "GitLab | Pages | Migrate legacy storage to zip format"
|
|
||||||
task migrate_legacy_storage: :gitlab_environment do
|
|
||||||
logger.info('Starting to migrate legacy pages storage to zip deployments')
|
|
||||||
|
|
||||||
result = ::Pages::MigrateFromLegacyStorageService.new(logger,
|
|
||||||
ignore_invalid_entries: ignore_invalid_entries,
|
|
||||||
mark_projects_as_not_deployed: mark_projects_as_not_deployed)
|
|
||||||
.execute_with_threads(threads: migration_threads, batch_size: batch_size)
|
|
||||||
|
|
||||||
logger.info("A total of #{result[:migrated] + result[:errored]} projects were processed.")
|
|
||||||
logger.info("- The #{result[:migrated]} projects migrated successfully")
|
|
||||||
logger.info("- The #{result[:errored]} projects failed to be migrated")
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "GitLab | Pages | DANGER: Removes data which was migrated from legacy storage on zip storage. Can be used if some bugs in migration are discovered and migration needs to be restarted from scratch."
|
|
||||||
task clean_migrated_zip_storage: :gitlab_environment do
|
|
||||||
destroyed_deployments = 0
|
|
||||||
|
|
||||||
logger.info("Starting to delete migrated pages deployments")
|
|
||||||
|
|
||||||
::PagesDeployment.migrated_from_legacy_storage.each_batch(of: batch_size) do |batch|
|
|
||||||
destroyed_deployments += batch.count
|
|
||||||
|
|
||||||
# we need to destroy associated files, so can't use delete_all
|
|
||||||
batch.destroy_all # rubocop: disable Cop/DestroyAll
|
|
||||||
|
|
||||||
logger.info("#{destroyed_deployments} deployments were deleted")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def logger
|
|
||||||
@logger ||= Logger.new($stdout)
|
|
||||||
end
|
|
||||||
|
|
||||||
def migration_threads
|
|
||||||
ENV.fetch('PAGES_MIGRATION_THREADS', '3').to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def batch_size
|
|
||||||
ENV.fetch('PAGES_MIGRATION_BATCH_SIZE', '10').to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def ignore_invalid_entries
|
|
||||||
Gitlab::Utils.to_boolean(
|
|
||||||
ENV.fetch('PAGES_MIGRATION_IGNORE_INVALID_ENTRIES', 'false')
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def mark_projects_as_not_deployed
|
|
||||||
Gitlab::Utils.to_boolean(
|
|
||||||
ENV.fetch('PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED', 'false')
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
namespace :deployments do
|
namespace :deployments do
|
||||||
task migrate_to_object_storage: :gitlab_environment do
|
task migrate_to_object_storage: :gitlab_environment do
|
||||||
logger = Logger.new($stdout)
|
logger = Logger.new($stdout)
|
||||||
|
|
|
@ -19162,6 +19162,12 @@ msgstr ""
|
||||||
msgid "If this email was added in error, you can remove it here: %{profile_emails_url}"
|
msgid "If this email was added in error, you can remove it here: %{profile_emails_url}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "If this is a mistake, you can %{link_start}unban them%{link_end}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "If this is a mistake, you can unban them: %{url}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}."
|
msgid "If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -42469,6 +42475,9 @@ msgstr ""
|
||||||
msgid "We want to be sure it is you, please confirm you are not a robot."
|
msgid "We want to be sure it is you, please confirm you are not a robot."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders."
|
msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -42484,6 +42493,12 @@ msgstr ""
|
||||||
msgid "We're experiencing difficulties and this tab content is currently unavailable."
|
msgid "We're experiencing difficulties and this tab content is currently unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "We've detected some unusual activity"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "We've detected unusual activity"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "We've found no vulnerabilities"
|
msgid "We've found no vulnerabilities"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -43077,9 +43092,21 @@ msgstr ""
|
||||||
msgid "Work in progress Limit"
|
msgid "Work in progress Limit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "WorkItem|Add"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "WorkItem|Add a child"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed."
|
msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "WorkItem|Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "WorkItem|Child items"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "WorkItem|Convert to work item"
|
msgid "WorkItem|Convert to work item"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -43092,6 +43119,9 @@ msgstr ""
|
||||||
msgid "WorkItem|New Task"
|
msgid "WorkItem|New Task"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "WorkItem|Select type"
|
msgid "WorkItem|Select type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -43304,6 +43334,12 @@ msgstr ""
|
||||||
msgid "You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}."
|
msgid "You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "You can adjust rules on auto-banning %{link_start}here%{link_end}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "You can adjust rules on auto-banning here: %{url}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "You can also create a project from the command line."
|
msgid "You can also create a project from the command line."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Verify', :runner, :reliable, quarantine: {
|
RSpec.describe 'Verify', :runner, :reliable do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
describe 'Parent-child pipelines dependent relationship' do
|
describe 'Parent-child pipelines dependent relationship' do
|
||||||
let!(:project) do
|
let!(:project) do
|
||||||
Resource::Project.fabricate_via_api! do |project|
|
Resource::Project.fabricate_via_api! do |project|
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Verify', :runner, :reliable, quarantine: {
|
RSpec.describe 'Verify', :runner, :reliable do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
describe 'Parent-child pipelines independent relationship' do
|
describe 'Parent-child pipelines independent relationship' do
|
||||||
let!(:project) do
|
let!(:project) do
|
||||||
Resource::Project.fabricate_via_api! do |project|
|
Resource::Project.fabricate_via_api! do |project|
|
||||||
|
|
|
@ -2,11 +2,7 @@
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Package' do
|
RSpec.describe 'Package' do
|
||||||
describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] }, quarantine: {
|
describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] } do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
let(:project) do
|
let(:project) do
|
||||||
Resource::Project.fabricate_via_api! do |project|
|
Resource::Project.fabricate_via_api! do |project|
|
||||||
project.name = 'project-with-registry'
|
project.name = 'project-with-registry'
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: {
|
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
describe 'Maven group level endpoint' do
|
describe 'Maven group level endpoint' do
|
||||||
include Runtime::Fixtures
|
include Runtime::Fixtures
|
||||||
include_context 'packages registry qa scenario'
|
include_context 'packages registry qa scenario'
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: {
|
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
describe 'Maven project level endpoint' do
|
describe 'Maven project level endpoint' do
|
||||||
let(:group_id) { 'com.gitlab.qa' }
|
let(:group_id) { 'com.gitlab.qa' }
|
||||||
let(:artifact_id) { "maven-#{SecureRandom.hex(8)}" }
|
let(:artifact_id) { "maven-#{SecureRandom.hex(8)}" }
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module QA
|
module QA
|
||||||
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: {
|
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do
|
||||||
only: { subdomain: %i[staging staging-canary] },
|
|
||||||
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188",
|
|
||||||
type: :investigating
|
|
||||||
} do
|
|
||||||
describe 'NuGet group level endpoint' do
|
describe 'NuGet group level endpoint' do
|
||||||
using RSpec::Parameterized::TableSyntax
|
using RSpec::Parameterized::TableSyntax
|
||||||
include Runtime::Fixtures
|
include Runtime::Fixtures
|
||||||
|
|
|
@ -1730,7 +1730,7 @@ RSpec.describe Projects::MergeRequestsController do
|
||||||
|
|
||||||
describe 'POST remove_wip' do
|
describe 'POST remove_wip' do
|
||||||
before do
|
before do
|
||||||
merge_request.title = merge_request.wip_title
|
merge_request.title = merge_request.draft_title
|
||||||
merge_request.save!
|
merge_request.save!
|
||||||
|
|
||||||
post :remove_wip,
|
post :remove_wip,
|
||||||
|
@ -1743,8 +1743,8 @@ RSpec.describe Projects::MergeRequestsController do
|
||||||
xhr: true
|
xhr: true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'removes the wip status' do
|
it 'removes the draft status' do
|
||||||
expect(merge_request.reload.title).to eq(merge_request.wipless_title)
|
expect(merge_request.reload.title).to eq(merge_request.draftless_title)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders MergeRequest as JSON' do
|
it 'renders MergeRequest as JSON' do
|
||||||
|
|
|
@ -56,7 +56,7 @@ exports[`Design management toolbar component renders design and updated data 1`]
|
||||||
buttonclass=""
|
buttonclass=""
|
||||||
buttonicon="archive"
|
buttonicon="archive"
|
||||||
buttonsize="medium"
|
buttonsize="medium"
|
||||||
buttonvariant="warning"
|
buttonvariant="default"
|
||||||
class="gl-ml-3"
|
class="gl-ml-3"
|
||||||
hasselecteddesigns="true"
|
hasselecteddesigns="true"
|
||||||
title="Archive design"
|
title="Archive design"
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { setHTMLFixture } from 'helpers/fixtures';
|
import { setHTMLFixture } from 'helpers/fixtures';
|
||||||
import * as createDefaultClient from '~/lib/graphql';
|
import * as createDefaultClient from '~/lib/graphql';
|
||||||
import initMRPopovers from '~/mr_popover/index';
|
import initIssuablePopovers from '~/issuable/popover/index';
|
||||||
|
|
||||||
createDefaultClient.default = jest.fn();
|
createDefaultClient.default = jest.fn();
|
||||||
|
|
||||||
describe('initMRPopovers', () => {
|
describe('initIssuablePopovers', () => {
|
||||||
let mr1;
|
let mr1;
|
||||||
let mr2;
|
let mr2;
|
||||||
let mr3;
|
let mr3;
|
||||||
|
@ -14,7 +14,7 @@ describe('initMRPopovers', () => {
|
||||||
<div id="one" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project">
|
<div id="one" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project">
|
||||||
MR1
|
MR1
|
||||||
</div>
|
</div>
|
||||||
<div id="two" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project">
|
<div id="two" class="gfm-merge_request" title="title" data-iid="1" data-project-path="group/project">
|
||||||
MR2
|
MR2
|
||||||
</div>
|
</div>
|
||||||
<div id="three" class="gfm-merge_request">
|
<div id="three" class="gfm-merge_request">
|
||||||
|
@ -32,14 +32,14 @@ describe('initMRPopovers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not add the same event listener twice', () => {
|
it('does not add the same event listener twice', () => {
|
||||||
initMRPopovers([mr1, mr1, mr2]);
|
initIssuablePopovers([mr1, mr1, mr2]);
|
||||||
|
|
||||||
expect(mr1.addEventListener).toHaveBeenCalledTimes(1);
|
expect(mr1.addEventListener).toHaveBeenCalledTimes(1);
|
||||||
expect(mr2.addEventListener).toHaveBeenCalledTimes(1);
|
expect(mr2.addEventListener).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not add listener if it does not have the necessary data attributes', () => {
|
it('does not add listener if it does not have the necessary data attributes', () => {
|
||||||
initMRPopovers([mr1, mr2, mr3]);
|
initIssuablePopovers([mr1, mr2, mr3]);
|
||||||
|
|
||||||
expect(mr3.addEventListener).not.toHaveBeenCalled();
|
expect(mr3.addEventListener).not.toHaveBeenCalled();
|
||||||
});
|
});
|
|
@ -1,6 +1,6 @@
|
||||||
import { shallowMount } from '@vue/test-utils';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import MRPopover from '~/mr_popover/components/mr_popover.vue';
|
import MRPopover from '~/issuable/popover/components/mr_popover.vue';
|
||||||
import CiIcon from '~/vue_shared/components/ci_icon.vue';
|
import CiIcon from '~/vue_shared/components/ci_icon.vue';
|
||||||
|
|
||||||
describe('MR Popover', () => {
|
describe('MR Popover', () => {
|
|
@ -1,13 +1,11 @@
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
import $ from 'jquery';
|
||||||
import waitForPromises from 'helpers/wait_for_promises';
|
import waitForPromises from 'helpers/wait_for_promises';
|
||||||
import initMRPopovers from '~/mr_popover/index';
|
|
||||||
import createStore from '~/notes/stores';
|
import createStore from '~/notes/stores';
|
||||||
import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue';
|
import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue';
|
||||||
import axios from '~/lib/utils/axios_utils';
|
import axios from '~/lib/utils/axios_utils';
|
||||||
|
|
||||||
jest.mock('~/mr_popover/index', () => jest.fn());
|
|
||||||
|
|
||||||
describe('system note component', () => {
|
describe('system note component', () => {
|
||||||
let vm;
|
let vm;
|
||||||
let props;
|
let props;
|
||||||
|
@ -76,10 +74,12 @@ describe('system note component', () => {
|
||||||
expect(vm.find('.system-note-message').html()).toContain('<span>closed</span>');
|
expect(vm.find('.system-note-message').html()).toContain('<span>closed</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initMRPopovers onMount', () => {
|
it('should renderGFM onMount', () => {
|
||||||
|
const renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
|
||||||
|
|
||||||
createComponent(props);
|
createComponent(props);
|
||||||
|
|
||||||
expect(initMRPopovers).toHaveBeenCalled();
|
expect(renderGFMSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders outdated code lines', async () => {
|
it('renders outdated code lines', async () => {
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||||
|
import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue';
|
||||||
|
|
||||||
|
describe('WorkItemLinks', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
const createComponent = () => {
|
||||||
|
wrapper = shallowMountExtended(WorkItemLinks, { propsData: { workItemId: '123' } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const findToggleButton = () => wrapper.findByTestId('toggle-links');
|
||||||
|
const findLinksBody = () => wrapper.findByTestId('links-body');
|
||||||
|
const findEmptyState = () => wrapper.findByTestId('links-empty');
|
||||||
|
const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form');
|
||||||
|
const findAddLinksForm = () => wrapper.findByTestId('add-links-form');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is collapsed by default', () => {
|
||||||
|
expect(findToggleButton().props('icon')).toBe('angle-down');
|
||||||
|
expect(findLinksBody().exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands on click toggle button', async () => {
|
||||||
|
findToggleButton().vm.$emit('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findToggleButton().props('icon')).toBe('angle-up');
|
||||||
|
expect(findLinksBody().exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays empty state if there are no links', async () => {
|
||||||
|
findToggleButton().vm.$emit('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findEmptyState().exists()).toBe(true);
|
||||||
|
expect(findToggleAddFormButton().exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('add link form', () => {
|
||||||
|
it('displays form on click add button and hides form on cancel', async () => {
|
||||||
|
findToggleButton().vm.$emit('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findEmptyState().exists()).toBe(true);
|
||||||
|
|
||||||
|
findToggleAddFormButton().vm.$emit('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findAddLinksForm().exists()).toBe(true);
|
||||||
|
|
||||||
|
findAddLinksForm().vm.$emit('cancel');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findAddLinksForm().exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -174,7 +174,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
||||||
|
|
||||||
context 'with draft argument' do
|
context 'with draft argument' do
|
||||||
before do
|
before do
|
||||||
merge_request_4.update!(title: MergeRequest.wip_title(merge_request_4.title))
|
merge_request_4.update!(title: MergeRequest.draft_title(merge_request_4.title))
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with draft: true argument' do
|
context 'with draft: true argument' do
|
||||||
|
|
|
@ -3,9 +3,62 @@
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
RSpec.describe Emails::AdminNotification do
|
RSpec.describe Emails::AdminNotification do
|
||||||
|
include EmailSpec::Matchers
|
||||||
|
include_context 'gitlab email notification'
|
||||||
|
|
||||||
it 'adds email methods to Notify' do
|
it 'adds email methods to Notify' do
|
||||||
subject.instance_methods.each do |email_method|
|
subject.instance_methods.each do |email_method|
|
||||||
expect(Notify).to be_respond_to(email_method)
|
expect(Notify).to be_respond_to(email_method)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'user_auto_banned_email' do
|
||||||
|
let_it_be(:admin) { create(:user) }
|
||||||
|
let_it_be(:user) { create(:user) }
|
||||||
|
|
||||||
|
let(:max_project_downloads) { 5 }
|
||||||
|
let(:time_period) { 600 }
|
||||||
|
|
||||||
|
subject do
|
||||||
|
Notify.user_auto_banned_email(
|
||||||
|
admin.id, user.id,
|
||||||
|
max_project_downloads: max_project_downloads,
|
||||||
|
within_seconds: time_period
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'an email sent from GitLab'
|
||||||
|
it_behaves_like 'it should not have Gmail Actions links'
|
||||||
|
it_behaves_like 'a user cannot unsubscribe through footer link'
|
||||||
|
it_behaves_like 'appearance header and footer enabled'
|
||||||
|
it_behaves_like 'appearance header and footer not enabled'
|
||||||
|
|
||||||
|
it 'is sent to the administrator' do
|
||||||
|
is_expected.to deliver_to admin.email
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has the correct subject' do
|
||||||
|
is_expected.to have_subject "We've detected unusual activity"
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the name of the user' do
|
||||||
|
is_expected.to have_body_text user.name
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the reason' do
|
||||||
|
is_expected.to have_body_text "due to them downloading more than 5 project repositories within 10 minutes"
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes a link to unban the user' do
|
||||||
|
is_expected.to have_body_text admin_users_url(filter: 'banned')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes a link to change the settings' do
|
||||||
|
is_expected.to have_body_text network_admin_application_settings_url(anchor: 'js-ip-limits-settings')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the email reason' do
|
||||||
|
is_expected.to have_body_text "You're receiving this email because of your account on localhost"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1472,20 +1472,20 @@ RSpec.describe MergeRequest, factory_default: :keep do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#wipless_title" do
|
describe "#draftless_title" do
|
||||||
subject { build_stubbed(:merge_request) }
|
subject { build_stubbed(:merge_request) }
|
||||||
|
|
||||||
['draft:', 'Draft: ', '[Draft]', '[DRAFT] '].each do |draft_prefix|
|
['draft:', 'Draft: ', '[Draft]', '[DRAFT] '].each do |draft_prefix|
|
||||||
it "removes a '#{draft_prefix}' prefix" do
|
it "removes a '#{draft_prefix}' prefix" do
|
||||||
wipless_title = subject.title
|
draftless_title = subject.title
|
||||||
subject.title = "#{draft_prefix}#{subject.title}"
|
subject.title = "#{draft_prefix}#{subject.title}"
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq wipless_title
|
expect(subject.draftless_title).to eq draftless_title
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is satisfies the #work_in_progress? method" do
|
it "is satisfies the #work_in_progress? method" do
|
||||||
subject.title = "#{draft_prefix}#{subject.title}"
|
subject.title = "#{draft_prefix}#{subject.title}"
|
||||||
subject.title = subject.wipless_title
|
subject.title = subject.draftless_title
|
||||||
|
|
||||||
expect(subject.work_in_progress?).to eq false
|
expect(subject.work_in_progress?).to eq false
|
||||||
end
|
end
|
||||||
|
@ -1497,58 +1497,58 @@ RSpec.describe MergeRequest, factory_default: :keep do
|
||||||
it "doesn't remove a '#{wip_prefix}' prefix" do
|
it "doesn't remove a '#{wip_prefix}' prefix" do
|
||||||
subject.title = "#{wip_prefix}#{subject.title}"
|
subject.title = "#{wip_prefix}#{subject.title}"
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq subject.title
|
expect(subject.draftless_title).to eq subject.title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'removes only draft prefix from the MR title' do
|
it 'removes only draft prefix from the MR title' do
|
||||||
subject.title = 'Draft: Implement feature called draft'
|
subject.title = 'Draft: Implement feature called draft'
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq 'Implement feature called draft'
|
expect(subject.draftless_title).to eq 'Implement feature called draft'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not remove WIP in the middle of the title' do
|
it 'does not remove WIP in the middle of the title' do
|
||||||
subject.title = 'Something with WIP in the middle'
|
subject.title = 'Something with WIP in the middle'
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq subject.title
|
expect(subject.draftless_title).to eq subject.title
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not remove Draft in the middle of the title' do
|
it 'does not remove Draft in the middle of the title' do
|
||||||
subject.title = 'Something with Draft in the middle'
|
subject.title = 'Something with Draft in the middle'
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq subject.title
|
expect(subject.draftless_title).to eq subject.title
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not remove WIP at the end of the title' do
|
it 'does not remove WIP at the end of the title' do
|
||||||
subject.title = 'Something ends with WIP'
|
subject.title = 'Something ends with WIP'
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq subject.title
|
expect(subject.draftless_title).to eq subject.title
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not remove Draft at the end of the title' do
|
it 'does not remove Draft at the end of the title' do
|
||||||
subject.title = 'Something ends with Draft'
|
subject.title = 'Something ends with Draft'
|
||||||
|
|
||||||
expect(subject.wipless_title).to eq subject.title
|
expect(subject.draftless_title).to eq subject.title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#wip_title" do
|
describe "#draft_title" do
|
||||||
it "adds the Draft: prefix to the title" do
|
it "adds the Draft: prefix to the title" do
|
||||||
wip_title = "Draft: #{subject.title}"
|
draft_title = "Draft: #{subject.title}"
|
||||||
|
|
||||||
expect(subject.wip_title).to eq wip_title
|
expect(subject.draft_title).to eq draft_title
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not add the Draft: prefix multiple times" do
|
it "does not add the Draft: prefix multiple times" do
|
||||||
wip_title = "Draft: #{subject.title}"
|
draft_title = "Draft: #{subject.title}"
|
||||||
subject.title = subject.wip_title
|
subject.title = subject.draft_title
|
||||||
subject.title = subject.wip_title
|
subject.title = subject.draft_title
|
||||||
|
|
||||||
expect(subject.wip_title).to eq wip_title
|
expect(subject.draft_title).to eq draft_title
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is satisfies the #work_in_progress? method" do
|
it "is satisfies the #work_in_progress? method" do
|
||||||
subject.title = subject.wip_title
|
subject.title = subject.draft_title
|
||||||
|
|
||||||
expect(subject.work_in_progress?).to eq true
|
expect(subject.work_in_progress?).to eq true
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,5 +24,18 @@ RSpec.describe Packages::Rubygems::CreateGemspecService do
|
||||||
expect(gemspec_file.file_sha1).not_to be_nil
|
expect(gemspec_file.file_sha1).not_to be_nil
|
||||||
expect(gemspec_file.file_sha256).not_to be_nil
|
expect(gemspec_file.file_sha256).not_to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with FIPS mode', :fips_mode do
|
||||||
|
it 'does not generate file_md5' do
|
||||||
|
expect { subject }.to change { package.package_files.count }.by(1)
|
||||||
|
|
||||||
|
gemspec_file = package.package_files.find_by(file_name: "#{gemspec.name}.gemspec")
|
||||||
|
expect(gemspec_file.file).not_to be_nil
|
||||||
|
expect(gemspec_file.size).not_to be_nil
|
||||||
|
expect(gemspec_file.file_md5).to be_nil
|
||||||
|
expect(gemspec_file.file_sha1).not_to be_nil
|
||||||
|
expect(gemspec_file.file_sha256).not_to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -333,14 +333,14 @@ RSpec.describe QuickActions::InterpretService do
|
||||||
|
|
||||||
shared_examples 'undraft command' do
|
shared_examples 'undraft command' do
|
||||||
it 'returns wip_event: "unwip" if content contains /draft' do
|
it 'returns wip_event: "unwip" if content contains /draft' do
|
||||||
issuable.update!(title: issuable.wip_title)
|
issuable.update!(title: issuable.draft_title)
|
||||||
_, updates, _ = service.execute(content, issuable)
|
_, updates, _ = service.execute(content, issuable)
|
||||||
|
|
||||||
expect(updates).to eq(wip_event: 'unwip')
|
expect(updates).to eq(wip_event: 'unwip')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns the unwip message' do
|
it 'returns the unwip message' do
|
||||||
issuable.update!(title: issuable.wip_title)
|
issuable.update!(title: issuable.draft_title)
|
||||||
_, _, message = service.execute(content, issuable)
|
_, _, message = service.execute(content, issuable)
|
||||||
|
|
||||||
expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
|
expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
|
||||||
|
|
|
@ -7,86 +7,6 @@ RSpec.describe 'gitlab:pages', :silence_stdout do
|
||||||
Rake.application.rake_require 'tasks/gitlab/pages'
|
Rake.application.rake_require 'tasks/gitlab/pages'
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'migrate_legacy_storage task' do
|
|
||||||
subject { run_rake_task('gitlab:pages:migrate_legacy_storage') }
|
|
||||||
|
|
||||||
it 'calls migration service' do
|
|
||||||
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything,
|
|
||||||
ignore_invalid_entries: false,
|
|
||||||
mark_projects_as_not_deployed: false) do |service|
|
|
||||||
expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original
|
|
||||||
end
|
|
||||||
|
|
||||||
subject
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses PAGES_MIGRATION_THREADS environment variable' do
|
|
||||||
stub_env('PAGES_MIGRATION_THREADS', '5')
|
|
||||||
|
|
||||||
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything,
|
|
||||||
ignore_invalid_entries: false,
|
|
||||||
mark_projects_as_not_deployed: false) do |service|
|
|
||||||
expect(service).to receive(:execute_with_threads).with(threads: 5, batch_size: 10).and_call_original
|
|
||||||
end
|
|
||||||
|
|
||||||
subject
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses PAGES_MIGRATION_BATCH_SIZE environment variable' do
|
|
||||||
stub_env('PAGES_MIGRATION_BATCH_SIZE', '100')
|
|
||||||
|
|
||||||
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything,
|
|
||||||
ignore_invalid_entries: false,
|
|
||||||
mark_projects_as_not_deployed: false) do |service|
|
|
||||||
expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 100).and_call_original
|
|
||||||
end
|
|
||||||
|
|
||||||
subject
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses PAGES_MIGRATION_IGNORE_INVALID_ENTRIES environment variable' do
|
|
||||||
stub_env('PAGES_MIGRATION_IGNORE_INVALID_ENTRIES', 'true')
|
|
||||||
|
|
||||||
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything,
|
|
||||||
ignore_invalid_entries: true,
|
|
||||||
mark_projects_as_not_deployed: false) do |service|
|
|
||||||
expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original
|
|
||||||
end
|
|
||||||
|
|
||||||
subject
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED environment variable' do
|
|
||||||
stub_env('PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED', 'true')
|
|
||||||
|
|
||||||
expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything,
|
|
||||||
ignore_invalid_entries: false,
|
|
||||||
mark_projects_as_not_deployed: true) do |service|
|
|
||||||
expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original
|
|
||||||
end
|
|
||||||
|
|
||||||
subject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'clean_migrated_zip_storage task' do
|
|
||||||
it 'removes only migrated deployments' do
|
|
||||||
regular_deployment = create(:pages_deployment)
|
|
||||||
migrated_deployment = create(:pages_deployment, :migrated)
|
|
||||||
|
|
||||||
regular_deployment.project.update_pages_deployment!(regular_deployment)
|
|
||||||
migrated_deployment.project.update_pages_deployment!(migrated_deployment)
|
|
||||||
|
|
||||||
expect(PagesDeployment.all).to contain_exactly(regular_deployment, migrated_deployment)
|
|
||||||
|
|
||||||
run_rake_task('gitlab:pages:clean_migrated_zip_storage')
|
|
||||||
|
|
||||||
expect(PagesDeployment.all).to contain_exactly(regular_deployment)
|
|
||||||
expect(PagesDeployment.find_by_id(regular_deployment.id)).not_to be_nil
|
|
||||||
expect(PagesDeployment.find_by_id(migrated_deployment.id)).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'gitlab:pages:deployments:migrate_to_object_storage' do
|
describe 'gitlab:pages:deployments:migrate_to_object_storage' do
|
||||||
subject { run_rake_task('gitlab:pages:deployments:migrate_to_object_storage') }
|
subject { run_rake_task('gitlab:pages:deployments:migrate_to_object_storage') }
|
||||||
|
|
||||||
|
|
|
@ -43,16 +43,12 @@ type artifactsUploadProcessor struct {
|
||||||
// Artifacts is like a Multipart but specific for artifacts upload.
|
// Artifacts is like a Multipart but specific for artifacts upload.
|
||||||
func Artifacts(myAPI *api.API, h http.Handler, p Preparer) http.Handler {
|
func Artifacts(myAPI *api.API, h http.Handler, p Preparer) http.Handler {
|
||||||
return myAPI.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
|
return myAPI.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
|
||||||
opts, err := p.Prepare(a)
|
|
||||||
if err != nil {
|
|
||||||
helper.Fail500(w, r, fmt.Errorf("UploadArtifacts: error preparing file storage options"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
format := r.URL.Query().Get(ArtifactFormatKey)
|
format := r.URL.Query().Get(ArtifactFormatKey)
|
||||||
|
mg := &artifactsUploadProcessor{
|
||||||
mg := &artifactsUploadProcessor{format: format, SavedFileTracker: SavedFileTracker{Request: r}}
|
format: format,
|
||||||
interceptMultipartFiles(w, r, h, a, mg, opts)
|
SavedFileTracker: SavedFileTracker{Request: r},
|
||||||
|
}
|
||||||
|
interceptMultipartFiles(w, r, h, mg, &eagerAuthorizer{a}, p)
|
||||||
}, "/authorize")
|
}, "/authorize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,8 +57,7 @@ func (a *artifactsUploadProcessor) generateMetadataFromZip(ctx context.Context,
|
||||||
defer metaWriter.Close()
|
defer metaWriter.Close()
|
||||||
|
|
||||||
metaOpts := &destination.UploadOpts{
|
metaOpts := &destination.UploadOpts{
|
||||||
LocalTempPath: os.TempDir(),
|
LocalTempPath: os.TempDir(),
|
||||||
TempFilePrefix: "metadata.gz",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := file.LocalPath
|
fileName := file.LocalPath
|
||||||
|
@ -87,7 +82,7 @@ func (a *artifactsUploadProcessor) generateMetadataFromZip(ctx context.Context,
|
||||||
done := make(chan saveResult)
|
done := make(chan saveResult)
|
||||||
go func() {
|
go func() {
|
||||||
var result saveResult
|
var result saveResult
|
||||||
result.FileHandler, result.error = destination.Upload(ctx, metaReader, -1, metaOpts)
|
result.FileHandler, result.error = destination.Upload(ctx, metaReader, -1, "metadata.gz", metaOpts)
|
||||||
|
|
||||||
done <- result
|
done <- result
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -23,7 +23,7 @@ func RequestBody(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fh, err := destination.Upload(r.Context(), r.Body, r.ContentLength, opts)
|
fh, err := destination.Upload(r.Context(), r.Body, r.ContentLength, "upload", opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.Fail500(w, r, fmt.Errorf("RequestBody: upload failed: %v", err))
|
helper.Fail500(w, r, fmt.Errorf("RequestBody: upload failed: %v", err))
|
||||||
return
|
return
|
||||||
|
|
|
@ -113,9 +113,9 @@ type consumer interface {
|
||||||
|
|
||||||
// Upload persists the provided reader content to all the location specified in opts. A cleanup will be performed once ctx is Done
|
// Upload persists the provided reader content to all the location specified in opts. A cleanup will be performed once ctx is Done
|
||||||
// Make sure the provided context will not expire before finalizing upload with GitLab Rails.
|
// Make sure the provided context will not expire before finalizing upload with GitLab Rails.
|
||||||
func Upload(ctx context.Context, reader io.Reader, size int64, opts *UploadOpts) (*FileHandler, error) {
|
func Upload(ctx context.Context, reader io.Reader, size int64, name string, opts *UploadOpts) (*FileHandler, error) {
|
||||||
fh := &FileHandler{
|
fh := &FileHandler{
|
||||||
Name: opts.TempFilePrefix,
|
Name: name,
|
||||||
RemoteID: opts.RemoteID,
|
RemoteID: opts.RemoteID,
|
||||||
RemoteURL: opts.RemoteURL,
|
RemoteURL: opts.RemoteURL,
|
||||||
}
|
}
|
||||||
|
@ -199,13 +199,13 @@ func Upload(ctx context.Context, reader io.Reader, size int64, opts *UploadOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := log.WithContextFields(ctx, log.Fields{
|
logger := log.WithContextFields(ctx, log.Fields{
|
||||||
"copied_bytes": fh.Size,
|
"copied_bytes": fh.Size,
|
||||||
"is_local": opts.IsLocalTempFile(),
|
"is_local": opts.IsLocalTempFile(),
|
||||||
"is_multipart": opts.IsMultipart(),
|
"is_multipart": opts.IsMultipart(),
|
||||||
"is_remote": !opts.IsLocalTempFile(),
|
"is_remote": !opts.IsLocalTempFile(),
|
||||||
"remote_id": opts.RemoteID,
|
"remote_id": opts.RemoteID,
|
||||||
"temp_file_prefix": opts.TempFilePrefix,
|
"client_mode": clientMode,
|
||||||
"client_mode": clientMode,
|
"filename": fh.Name,
|
||||||
})
|
})
|
||||||
|
|
||||||
if opts.IsLocalTempFile() {
|
if opts.IsLocalTempFile() {
|
||||||
|
@ -226,7 +226,7 @@ func (fh *FileHandler) newLocalFile(ctx context.Context, opts *UploadOpts) (cons
|
||||||
return nil, fmt.Errorf("newLocalFile: mkdir %q: %v", opts.LocalTempPath, err)
|
return nil, fmt.Errorf("newLocalFile: mkdir %q: %v", opts.LocalTempPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := ioutil.TempFile(opts.LocalTempPath, opts.TempFilePrefix)
|
file, err := ioutil.TempFile(opts.LocalTempPath, "gitlab-workhorse-upload")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("newLocalFile: create file: %v", err)
|
return nil, fmt.Errorf("newLocalFile: create file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,8 +47,8 @@ func TestUploadWrongSize(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tmpFolder)
|
defer os.RemoveAll(tmpFolder)
|
||||||
|
|
||||||
opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file"}
|
opts := &destination.UploadOpts{LocalTempPath: tmpFolder}
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, "upload", opts)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
_, isSizeError := err.(destination.SizeError)
|
_, isSizeError := err.(destination.SizeError)
|
||||||
require.True(t, isSizeError, "Should fail with SizeError")
|
require.True(t, isSizeError, "Should fail with SizeError")
|
||||||
|
@ -63,8 +63,8 @@ func TestUploadWithKnownSizeExceedLimit(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tmpFolder)
|
defer os.RemoveAll(tmpFolder)
|
||||||
|
|
||||||
opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file", MaximumSize: test.ObjectSize - 1}
|
opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1}
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
_, isSizeError := err.(destination.SizeError)
|
_, isSizeError := err.(destination.SizeError)
|
||||||
require.True(t, isSizeError, "Should fail with SizeError")
|
require.True(t, isSizeError, "Should fail with SizeError")
|
||||||
|
@ -79,8 +79,8 @@ func TestUploadWithUnknownSizeExceedLimit(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tmpFolder)
|
defer os.RemoveAll(tmpFolder)
|
||||||
|
|
||||||
opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file", MaximumSize: test.ObjectSize - 1}
|
opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1}
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), -1, opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), -1, "upload", opts)
|
||||||
require.Equal(t, err, destination.ErrEntityTooLarge)
|
require.Equal(t, err, destination.ErrEntityTooLarge)
|
||||||
require.Nil(t, fh)
|
require.Nil(t, fh)
|
||||||
}
|
}
|
||||||
|
@ -117,7 +117,7 @@ func TestUploadWrongETag(t *testing.T) {
|
||||||
osStub.InitiateMultipartUpload(test.ObjectPath)
|
osStub.InitiateMultipartUpload(test.ObjectPath)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts)
|
||||||
require.Nil(t, fh)
|
require.Nil(t, fh)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Equal(t, 1, osStub.PutsCnt(), "File not uploaded")
|
require.Equal(t, 1, osStub.PutsCnt(), "File not uploaded")
|
||||||
|
@ -191,13 +191,12 @@ func TestUpload(t *testing.T) {
|
||||||
|
|
||||||
if spec.local {
|
if spec.local {
|
||||||
opts.LocalTempPath = tmpFolder
|
opts.LocalTempPath = tmpFolder
|
||||||
opts.TempFilePrefix = "test-file"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, fh)
|
require.NotNil(t, fh)
|
||||||
|
|
||||||
|
@ -211,9 +210,6 @@ func TestUpload(t *testing.T) {
|
||||||
|
|
||||||
dir := path.Dir(fh.LocalPath)
|
dir := path.Dir(fh.LocalPath)
|
||||||
require.Equal(t, opts.LocalTempPath, dir)
|
require.Equal(t, opts.LocalTempPath, dir)
|
||||||
filename := path.Base(fh.LocalPath)
|
|
||||||
beginsWithPrefix := strings.HasPrefix(filename, opts.TempFilePrefix)
|
|
||||||
require.True(t, beginsWithPrefix, fmt.Sprintf("LocalPath filename %q do not begin with TempFilePrefix %q", filename, opts.TempFilePrefix))
|
|
||||||
} else {
|
} else {
|
||||||
require.Empty(t, fh.LocalPath, "LocalPath must be empty for non local uploads")
|
require.Empty(t, fh.LocalPath, "LocalPath must be empty for non local uploads")
|
||||||
}
|
}
|
||||||
|
@ -291,7 +287,7 @@ func TestUploadWithS3WorkhorseClient(t *testing.T) {
|
||||||
MaximumSize: tc.maxSize,
|
MaximumSize: tc.maxSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, &opts)
|
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, "upload", &opts)
|
||||||
|
|
||||||
if tc.expectedErr == nil {
|
if tc.expectedErr == nil {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -324,7 +320,7 @@ func TestUploadWithAzureWorkhorseClient(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts)
|
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
test.GoCloudObjectExists(t, bucketDir, remoteObject)
|
test.GoCloudObjectExists(t, bucketDir, remoteObject)
|
||||||
|
@ -349,7 +345,7 @@ func TestUploadWithUnknownGoCloudScheme(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts)
|
_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -375,7 +371,7 @@ func TestUploadMultipartInBodyFailure(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts)
|
||||||
require.Nil(t, fh)
|
require.Nil(t, fh)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.EqualError(t, err, test.MultipartUploadInternalError().Error())
|
require.EqualError(t, err, test.MultipartUploadInternalError().Error())
|
||||||
|
@ -468,7 +464,7 @@ func TestUploadRemoteFileWithLimit(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fh, err := destination.Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, &opts)
|
fh, err := destination.Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, "upload", &opts)
|
||||||
|
|
||||||
if tc.expectedErr == nil {
|
if tc.expectedErr == nil {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -22,7 +22,8 @@ type partsEtagMap map[int]string
|
||||||
// Instead of storing objects it will just save md5sum.
|
// Instead of storing objects it will just save md5sum.
|
||||||
type ObjectstoreStub struct {
|
type ObjectstoreStub struct {
|
||||||
// bucket contains md5sum of uploaded objects
|
// bucket contains md5sum of uploaded objects
|
||||||
bucket map[string]string
|
bucket map[string]string
|
||||||
|
contents map[string][]byte
|
||||||
// overwriteMD5 contains overwrites for md5sum that should be return instead of the regular hash
|
// overwriteMD5 contains overwrites for md5sum that should be return instead of the regular hash
|
||||||
overwriteMD5 map[string]string
|
overwriteMD5 map[string]string
|
||||||
// multipart is a map of MultipartUploads
|
// multipart is a map of MultipartUploads
|
||||||
|
@ -48,6 +49,7 @@ func StartObjectStoreWithCustomMD5(md5Hashes map[string]string) (*ObjectstoreStu
|
||||||
multipart: make(map[string]partsEtagMap),
|
multipart: make(map[string]partsEtagMap),
|
||||||
overwriteMD5: make(map[string]string),
|
overwriteMD5: make(map[string]string),
|
||||||
headers: make(map[string]*http.Header),
|
headers: make(map[string]*http.Header),
|
||||||
|
contents: make(map[string][]byte),
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range md5Hashes {
|
for k, v := range md5Hashes {
|
||||||
|
@ -82,6 +84,15 @@ func (o *ObjectstoreStub) GetObjectMD5(path string) string {
|
||||||
return o.bucket[path]
|
return o.bucket[path]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetObject returns the contents of the uploaded object. The caller must
|
||||||
|
// not modify the byte slice.
|
||||||
|
func (o *ObjectstoreStub) GetObject(path string) []byte {
|
||||||
|
o.m.Lock()
|
||||||
|
defer o.m.Unlock()
|
||||||
|
|
||||||
|
return o.contents[path]
|
||||||
|
}
|
||||||
|
|
||||||
// GetHeader returns a given HTTP header of the object uploaded to the path
|
// GetHeader returns a given HTTP header of the object uploaded to the path
|
||||||
func (o *ObjectstoreStub) GetHeader(path, key string) string {
|
func (o *ObjectstoreStub) GetHeader(path, key string) string {
|
||||||
o.m.Lock()
|
o.m.Lock()
|
||||||
|
@ -154,11 +165,11 @@ func (o *ObjectstoreStub) putObject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
etag, overwritten := o.overwriteMD5[objectPath]
|
etag, overwritten := o.overwriteMD5[objectPath]
|
||||||
if !overwritten {
|
if !overwritten {
|
||||||
|
buf, _ := io.ReadAll(r.Body)
|
||||||
|
o.contents[objectPath] = buf
|
||||||
hasher := md5.New()
|
hasher := md5.New()
|
||||||
io.Copy(hasher, r.Body)
|
hasher.Write(buf)
|
||||||
|
etag = hex.EncodeToString(hasher.Sum(nil))
|
||||||
checksum := hasher.Sum(nil)
|
|
||||||
etag = hex.EncodeToString(checksum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
o.headers[objectPath] = &r.Header
|
o.headers[objectPath] = &r.Header
|
||||||
|
|
|
@ -29,8 +29,6 @@ type ObjectStorageConfig struct {
|
||||||
|
|
||||||
// UploadOpts represents all the options available for saving a file to object store
|
// UploadOpts represents all the options available for saving a file to object store
|
||||||
type UploadOpts struct {
|
type UploadOpts struct {
|
||||||
// TempFilePrefix is the prefix used to create temporary local file
|
|
||||||
TempFilePrefix string
|
|
||||||
// LocalTempPath is the directory where to write a local copy of the file
|
// LocalTempPath is the directory where to write a local copy of the file
|
||||||
LocalTempPath string
|
LocalTempPath string
|
||||||
// RemoteID is the remote ObjectID provided by GitLab
|
// RemoteID is the remote ObjectID provided by GitLab
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
package upload
|
package upload
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/api"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/api"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Multipart is a request middleware. If the request has a MIME multipart
|
// Multipart is a request middleware. If the request has a MIME multipart
|
||||||
|
@ -17,12 +15,19 @@ func Multipart(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler {
|
||||||
return rails.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
|
return rails.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
|
||||||
s := &SavedFileTracker{Request: r}
|
s := &SavedFileTracker{Request: r}
|
||||||
|
|
||||||
opts, err := p.Prepare(a)
|
interceptMultipartFiles(w, r, h, s, &eagerAuthorizer{a}, p)
|
||||||
if err != nil {
|
|
||||||
helper.Fail500(w, r, fmt.Errorf("Multipart: error preparing file storage options"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interceptMultipartFiles(w, r, h, a, s, opts)
|
|
||||||
}, "/authorize")
|
}, "/authorize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SkipRailsPreAuthMultipart behaves like Multipart except it does not
|
||||||
|
// pre-authorize with Rails. It is intended for use on catch-all routes
|
||||||
|
// where we cannot pre-authorize both because we don't know which Rails
|
||||||
|
// endpoint to call, and because eagerly pre-authorizing would add too
|
||||||
|
// much overhead.
|
||||||
|
func SkipRailsPreAuthMultipart(tempPath string, h http.Handler, p Preparer) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := &SavedFileTracker{Request: r}
|
||||||
|
fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}}
|
||||||
|
interceptMultipartFiles(w, r, h, s, fa, p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -62,13 +62,14 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type rewriter struct {
|
type rewriter struct {
|
||||||
writer *multipart.Writer
|
writer *multipart.Writer
|
||||||
preauth *api.Response
|
fileAuthorizer
|
||||||
|
Preparer
|
||||||
filter MultipartFormProcessor
|
filter MultipartFormProcessor
|
||||||
finalizedFields map[string]bool
|
finalizedFields map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, preauth *api.Response, filter MultipartFormProcessor, opts *destination.UploadOpts) error {
|
func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, filter MultipartFormProcessor, fa fileAuthorizer, preparer Preparer) error {
|
||||||
// Create multipart reader
|
// Create multipart reader
|
||||||
reader, err := r.MultipartReader()
|
reader, err := r.MultipartReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -83,7 +84,8 @@ func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, pr
|
||||||
|
|
||||||
rew := &rewriter{
|
rew := &rewriter{
|
||||||
writer: writer,
|
writer: writer,
|
||||||
preauth: preauth,
|
fileAuthorizer: fa,
|
||||||
|
Preparer: preparer,
|
||||||
filter: filter,
|
filter: filter,
|
||||||
finalizedFields: make(map[string]bool),
|
finalizedFields: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
@ -108,7 +110,7 @@ func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, pr
|
||||||
}
|
}
|
||||||
|
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
err = rew.handleFilePart(r.Context(), name, p, opts)
|
err = rew.handleFilePart(r, name, p)
|
||||||
} else {
|
} else {
|
||||||
err = rew.copyPart(r.Context(), name, p)
|
err = rew.copyPart(r.Context(), name, p)
|
||||||
}
|
}
|
||||||
|
@ -128,7 +130,7 @@ func parseAndNormalizeContentDisposition(header textproto.MIMEHeader) (string, s
|
||||||
return params["name"], params["filename"]
|
return params["name"], params["filename"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipart.Part, opts *destination.UploadOpts) error {
|
func (rew *rewriter) handleFilePart(r *http.Request, name string, p *multipart.Part) error {
|
||||||
if rew.filter.Count() >= maxFilesAllowed {
|
if rew.filter.Count() >= maxFilesAllowed {
|
||||||
return ErrTooManyFilesUploaded
|
return ErrTooManyFilesUploaded
|
||||||
}
|
}
|
||||||
|
@ -141,30 +143,34 @@ func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipa
|
||||||
return fmt.Errorf("illegal filename: %q", filename)
|
return fmt.Errorf("illegal filename: %q", filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.TempFilePrefix = filename
|
apiResponse, err := rew.AuthorizeFile(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts, err := rew.Prepare(apiResponse)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var inputReader io.ReadCloser
|
var inputReader io.ReadCloser
|
||||||
var err error
|
ctx := r.Context()
|
||||||
|
if imageType := exif.FileTypeFromSuffix(filename); imageType != exif.TypeUnknown {
|
||||||
imageType := exif.FileTypeFromSuffix(filename)
|
|
||||||
switch {
|
|
||||||
case imageType != exif.TypeUnknown:
|
|
||||||
inputReader, err = handleExifUpload(ctx, p, filename, imageType)
|
inputReader, err = handleExifUpload(ctx, p, filename, imageType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case rew.preauth.ProcessLsif:
|
} else if apiResponse.ProcessLsif {
|
||||||
inputReader, err = handleLsifUpload(ctx, p, opts.LocalTempPath, filename, rew.preauth)
|
inputReader, err = handleLsifUpload(ctx, p, opts.LocalTempPath, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
} else {
|
||||||
inputReader = ioutil.NopCloser(p)
|
inputReader = ioutil.NopCloser(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer inputReader.Close()
|
defer inputReader.Close()
|
||||||
|
|
||||||
fh, err := destination.Upload(ctx, inputReader, -1, opts)
|
fh, err := destination.Upload(ctx, inputReader, -1, filename, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case destination.ErrEntityTooLarge, exif.ErrRemovingExif:
|
case destination.ErrEntityTooLarge, exif.ErrRemovingExif:
|
||||||
|
@ -267,7 +273,7 @@ func isJPEG(r io.Reader) bool {
|
||||||
return http.DetectContentType(buf) == "image/jpeg"
|
return http.DetectContentType(buf) == "image/jpeg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLsifUpload(ctx context.Context, reader io.Reader, tempPath, filename string, preauth *api.Response) (io.ReadCloser, error) {
|
func handleLsifUpload(ctx context.Context, reader io.Reader, tempPath, filename string) (io.ReadCloser, error) {
|
||||||
parserConfig := parser.Config{
|
parserConfig := parser.Config{
|
||||||
TempPath: tempPath,
|
TempPath: tempPath,
|
||||||
}
|
}
|
||||||
|
@ -291,3 +297,15 @@ func (rew *rewriter) copyPart(ctx context.Context, name string, p *multipart.Par
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fileAuthorizer interface {
|
||||||
|
AuthorizeFile(*http.Request) (*api.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type eagerAuthorizer struct{ response *api.Response }
|
||||||
|
|
||||||
|
func (ea *eagerAuthorizer) AuthorizeFile(r *http.Request) (*api.Response, error) {
|
||||||
|
return ea.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fileAuthorizer = &eagerAuthorizer{}
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
package upload
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SkipRailsAuthorizer implements a fake PreAuthorizer that does not call
|
|
||||||
// the gitlab-rails API. It must be fast because it gets called on each
|
|
||||||
// request proxied to Rails.
|
|
||||||
type SkipRailsAuthorizer struct {
|
|
||||||
// TempPath is a directory where workhorse can store files that can later
|
|
||||||
// be accessed by gitlab-rails.
|
|
||||||
TempPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *SkipRailsAuthorizer) PreAuthorizeHandler(next api.HandleFunc, _ string) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
next(w, r, &api.Response{TempPath: l.TempPath})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -40,13 +40,13 @@ type MultipartFormProcessor interface {
|
||||||
|
|
||||||
// interceptMultipartFiles is the core of the implementation of
|
// interceptMultipartFiles is the core of the implementation of
|
||||||
// Multipart.
|
// Multipart.
|
||||||
func interceptMultipartFiles(w http.ResponseWriter, r *http.Request, h http.Handler, preauth *api.Response, filter MultipartFormProcessor, opts *destination.UploadOpts) {
|
func interceptMultipartFiles(w http.ResponseWriter, r *http.Request, h http.Handler, filter MultipartFormProcessor, fa fileAuthorizer, p Preparer) {
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
writer := multipart.NewWriter(&body)
|
writer := multipart.NewWriter(&body)
|
||||||
defer writer.Close()
|
defer writer.Close()
|
||||||
|
|
||||||
// Rewrite multipart form data
|
// Rewrite multipart form data
|
||||||
err := rewriteFormFilesFromMultipart(r, writer, preauth, filter, opts)
|
err := rewriteFormFilesFromMultipart(r, writer, filter, fa, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case ErrInjectedClientParam:
|
case ErrInjectedClientParam:
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -66,19 +67,14 @@ func TestUploadHandlerForwardingRawData(t *testing.T) {
|
||||||
httpRequest, err := http.NewRequest("PATCH", ts.URL+"/url/path", bytes.NewBufferString("REQUEST"))
|
httpRequest, err := http.NewRequest("PATCH", ts.URL+"/url/path", bytes.NewBufferString("REQUEST"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
tempPath := t.TempDir()
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := newProxy(ts.URL)
|
handler := newProxy(ts.URL)
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}}
|
||||||
preparer := &DefaultPreparer{}
|
preparer := &DefaultPreparer{}
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, handler, apiResponse, nil, opts)
|
interceptMultipartFiles(response, httpRequest, handler, nil, fa, preparer)
|
||||||
|
|
||||||
require.Equal(t, 202, response.Code)
|
require.Equal(t, 202, response.Code)
|
||||||
require.Equal(t, "RESPONSE", response.Body.String(), "response body")
|
require.Equal(t, "RESPONSE", response.Body.String(), "response body")
|
||||||
|
@ -86,10 +82,7 @@ func TestUploadHandlerForwardingRawData(t *testing.T) {
|
||||||
|
|
||||||
func TestUploadHandlerRewritingMultiPartData(t *testing.T) {
|
func TestUploadHandlerRewritingMultiPartData(t *testing.T) {
|
||||||
var filePath string
|
var filePath string
|
||||||
|
tempPath := t.TempDir()
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
ts := testhelper.TestServerWithHandler(regexp.MustCompile(`/url/path\z`), func(w http.ResponseWriter, r *http.Request) {
|
ts := testhelper.TestServerWithHandler(regexp.MustCompile(`/url/path\z`), func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "PUT", r.Method, "method")
|
require.Equal(t, "PUT", r.Method, "method")
|
||||||
|
@ -144,12 +137,10 @@ func TestUploadHandlerRewritingMultiPartData(t *testing.T) {
|
||||||
|
|
||||||
handler := newProxy(ts.URL)
|
handler := newProxy(ts.URL)
|
||||||
|
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}}
|
||||||
preparer := &DefaultPreparer{}
|
preparer := &DefaultPreparer{}
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts)
|
interceptMultipartFiles(response, httpRequest, handler, &testFormProcessor{}, fa, preparer)
|
||||||
require.Equal(t, 202, response.Code)
|
require.Equal(t, 202, response.Code)
|
||||||
|
|
||||||
cancel() // this will trigger an async cleanup
|
cancel() // this will trigger an async cleanup
|
||||||
|
@ -159,10 +150,6 @@ func TestUploadHandlerRewritingMultiPartData(t *testing.T) {
|
||||||
func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) {
|
func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) {
|
||||||
var filePath string
|
var filePath string
|
||||||
|
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
field string
|
field string
|
||||||
|
@ -213,12 +200,8 @@ func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) {
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := newProxy(ts.URL)
|
handler := newProxy(ts.URL)
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts)
|
testInterceptMultipartFiles(t, response, httpRequest, handler, &testFormProcessor{})
|
||||||
require.Equal(t, test.response, response.Code)
|
require.Equal(t, test.response, response.Code)
|
||||||
|
|
||||||
cancel() // this will trigger an async cleanup
|
cancel() // this will trigger an async cleanup
|
||||||
|
@ -228,10 +211,6 @@ func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUploadProcessingField(t *testing.T) {
|
func TestUploadProcessingField(t *testing.T) {
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
writer := multipart.NewWriter(&buffer)
|
writer := multipart.NewWriter(&buffer)
|
||||||
|
@ -243,12 +222,8 @@ func TestUploadProcessingField(t *testing.T) {
|
||||||
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts)
|
testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &testFormProcessor{})
|
||||||
|
|
||||||
require.Equal(t, 500, response.Code)
|
require.Equal(t, 500, response.Code)
|
||||||
}
|
}
|
||||||
|
@ -256,15 +231,11 @@ func TestUploadProcessingField(t *testing.T) {
|
||||||
func TestUploadingMultipleFiles(t *testing.T) {
|
func TestUploadingMultipleFiles(t *testing.T) {
|
||||||
testhelper.ConfigureSecret()
|
testhelper.ConfigureSecret()
|
||||||
|
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
writer := multipart.NewWriter(&buffer)
|
writer := multipart.NewWriter(&buffer)
|
||||||
for i := 0; i < 11; i++ {
|
for i := 0; i < 11; i++ {
|
||||||
_, err = writer.CreateFormFile(fmt.Sprintf("file %v", i), "my.file")
|
_, err := writer.CreateFormFile(fmt.Sprintf("file %v", i), "my.file")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
require.NoError(t, writer.Close())
|
require.NoError(t, writer.Close())
|
||||||
|
@ -274,23 +245,18 @@ func TestUploadingMultipleFiles(t *testing.T) {
|
||||||
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts)
|
testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &testFormProcessor{})
|
||||||
|
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
require.Equal(t, "upload request contains more than 10 files\n", response.Body.String())
|
require.Equal(t, "upload request contains more than 10 files\n", response.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUploadProcessingFile(t *testing.T) {
|
func TestUploadProcessingFile(t *testing.T) {
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
testhelper.ConfigureSecret()
|
||||||
require.NoError(t, err)
|
tempPath := t.TempDir()
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
_, testServer := test.StartObjectStore()
|
objectStore, testServer := test.StartObjectStore()
|
||||||
defer testServer.Close()
|
defer testServer.Close()
|
||||||
|
|
||||||
storeUrl := testServer.URL + test.ObjectPath
|
storeUrl := testServer.URL + test.ObjectPath
|
||||||
|
@ -298,21 +264,24 @@ func TestUploadProcessingFile(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
preauth *api.Response
|
preauth *api.Response
|
||||||
|
content func(t *testing.T) []byte
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "FileStore Upload",
|
name: "FileStore Upload",
|
||||||
preauth: &api.Response{TempPath: tempPath},
|
preauth: &api.Response{TempPath: tempPath},
|
||||||
|
content: func(t *testing.T) []byte {
|
||||||
|
entries, err := os.ReadDir(tempPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
content, err := os.ReadFile(path.Join(tempPath, entries[0].Name()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return content
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ObjectStore Upload",
|
name: "ObjectStore Upload",
|
||||||
preauth: &api.Response{RemoteObject: api.RemoteObject{StoreURL: storeUrl}},
|
preauth: &api.Response{RemoteObject: api.RemoteObject{StoreURL: storeUrl, ID: "123"}},
|
||||||
},
|
content: func(*testing.T) []byte { return objectStore.GetObject(test.ObjectPath) },
|
||||||
{
|
|
||||||
name: "ObjectStore and FileStore Upload",
|
|
||||||
preauth: &api.Response{
|
|
||||||
TempPath: tempPath,
|
|
||||||
RemoteObject: api.RemoteObject{StoreURL: storeUrl},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,26 +299,20 @@ func TestUploadProcessingFile(t *testing.T) {
|
||||||
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
fa := &eagerAuthorizer{test.preauth}
|
||||||
preparer := &DefaultPreparer{}
|
preparer := &DefaultPreparer{}
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts)
|
interceptMultipartFiles(response, httpRequest, nilHandler, &testFormProcessor{}, fa, preparer)
|
||||||
|
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "test", string(test.content(t)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidFileNames(t *testing.T) {
|
func TestInvalidFileNames(t *testing.T) {
|
||||||
testhelper.ConfigureSecret()
|
testhelper.ConfigureSecret()
|
||||||
|
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
for _, testCase := range []struct {
|
for _, testCase := range []struct {
|
||||||
filename string
|
filename string
|
||||||
code int
|
code int
|
||||||
|
@ -376,24 +339,14 @@ func TestInvalidFileNames(t *testing.T) {
|
||||||
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
httpRequest.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &SavedFileTracker{Request: httpRequest})
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts)
|
|
||||||
require.Equal(t, testCase.code, response.Code)
|
require.Equal(t, testCase.code, response.Code)
|
||||||
require.Equal(t, testCase.expectedPrefix, opts.TempFilePrefix)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentDispositionRewrite(t *testing.T) {
|
func TestContentDispositionRewrite(t *testing.T) {
|
||||||
testhelper.ConfigureSecret()
|
testhelper.ConfigureSecret()
|
||||||
|
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
desc string
|
desc string
|
||||||
header string
|
header string
|
||||||
|
@ -442,12 +395,7 @@ func TestContentDispositionRewrite(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
testInterceptMultipartFiles(t, response, httpRequest, customHandler, &SavedFileTracker{Request: httpRequest})
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, customHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts)
|
|
||||||
|
|
||||||
upstreamRequest, err := http.ReadRequest(bufio.NewReader(&upstreamRequestBuffer))
|
upstreamRequest, err := http.ReadRequest(bufio.NewReader(&upstreamRequestBuffer))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -534,10 +482,6 @@ func TestUploadHandlerRemovingExifCorruptedFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runUploadTest(t *testing.T, image []byte, filename string, httpCode int, tsHandler func(http.ResponseWriter, *http.Request)) {
|
func runUploadTest(t *testing.T, image []byte, filename string, httpCode int, tsHandler func(http.ResponseWriter, *http.Request)) {
|
||||||
tempPath, err := ioutil.TempDir("", "uploads")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempPath)
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
writer := multipart.NewWriter(&buffer)
|
writer := multipart.NewWriter(&buffer)
|
||||||
|
@ -565,12 +509,8 @@ func runUploadTest(t *testing.T, image []byte, filename string, httpCode int, ts
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := newProxy(ts.URL)
|
handler := newProxy(ts.URL)
|
||||||
apiResponse := &api.Response{TempPath: tempPath}
|
|
||||||
preparer := &DefaultPreparer{}
|
|
||||||
opts, err := preparer.Prepare(apiResponse)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts)
|
testInterceptMultipartFiles(t, response, httpRequest, handler, &testFormProcessor{})
|
||||||
require.Equal(t, httpCode, response.Code)
|
require.Equal(t, httpCode, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -587,3 +527,12 @@ func waitUntilDeleted(t *testing.T, path string) {
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
require.True(t, os.IsNotExist(err), "expected the file to be deleted")
|
require.True(t, os.IsNotExist(err), "expected the file to be deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testInterceptMultipartFiles(t *testing.T, w http.ResponseWriter, r *http.Request, h http.Handler, filter MultipartFormProcessor) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fa := &eagerAuthorizer{&api.Response{TempPath: t.TempDir()}}
|
||||||
|
preparer := &DefaultPreparer{}
|
||||||
|
|
||||||
|
interceptMultipartFiles(w, r, h, filter, fa, preparer)
|
||||||
|
}
|
||||||
|
|
|
@ -223,7 +223,7 @@ func configureRoutes(u *upstream) {
|
||||||
mimeMultipartUploader := upload.Multipart(api, signingProxy, preparer)
|
mimeMultipartUploader := upload.Multipart(api, signingProxy, preparer)
|
||||||
|
|
||||||
uploadPath := path.Join(u.DocumentRoot, "uploads/tmp")
|
uploadPath := path.Join(u.DocumentRoot, "uploads/tmp")
|
||||||
tempfileMultipartProxy := upload.Multipart(&upload.SkipRailsAuthorizer{TempPath: uploadPath}, proxy, preparer)
|
tempfileMultipartProxy := upload.SkipRailsPreAuthMultipart(uploadPath, proxy, preparer)
|
||||||
ciAPIProxyQueue := queueing.QueueRequests("ci_api_job_requests", tempfileMultipartProxy, u.APILimit, u.APIQueueLimit, u.APIQueueTimeout)
|
ciAPIProxyQueue := queueing.QueueRequests("ci_api_job_requests", tempfileMultipartProxy, u.APILimit, u.APIQueueLimit, u.APIQueueTimeout)
|
||||||
ciAPILongPolling := builds.RegisterHandler(ciAPIProxyQueue, redis.WatchKey, u.APICILongPollingDuration)
|
ciAPILongPolling := builds.RegisterHandler(ciAPIProxyQueue, redis.WatchKey, u.APICILongPollingDuration)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue